vendor: import KillerPDF 1.7.5 source (GPL-3.0) as the base for MMD PDF
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*.cs]
|
||||||
|
# IDE0130 (namespace must match folder structure) is deliberately OFF. The KillerUI refactor
|
||||||
|
# keeps ONE flat KillerPDF.Features namespace across the Features/<Name>/ folders - every
|
||||||
|
# partial of a class must share a single namespace, and Killendar (the family's reference
|
||||||
|
# implementation) uses the same flat shape. Folder-matching namespaces would fight both.
|
||||||
|
dotnet_diagnostic.IDE0130.severity = none
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Default: detect text vs binary, normalize text to LF in the repo
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# .NET project / IDE files (Visual Studio expects CRLF)
|
||||||
|
*.sln text eol=crlf
|
||||||
|
*.csproj text eol=crlf
|
||||||
|
*.vbproj text eol=crlf
|
||||||
|
*.vcxproj text eol=crlf
|
||||||
|
*.pubxml text eol=crlf
|
||||||
|
*.props text eol=crlf
|
||||||
|
*.targets text eol=crlf
|
||||||
|
|
||||||
|
# C# / XAML source (Windows convention)
|
||||||
|
*.cs text eol=crlf
|
||||||
|
*.xaml text eol=crlf
|
||||||
|
|
||||||
|
# Config XML / XSD used by .NET tooling
|
||||||
|
*.xml text eol=crlf
|
||||||
|
*.xsd text eol=crlf
|
||||||
|
*.config text eol=crlf
|
||||||
|
|
||||||
|
# PowerShell (Windows native)
|
||||||
|
*.ps1 text eol=crlf
|
||||||
|
*.psm1 text eol=crlf
|
||||||
|
*.psd1 text eol=crlf
|
||||||
|
|
||||||
|
# Web / landing page assets
|
||||||
|
*.html text eol=lf
|
||||||
|
*.css text eol=lf
|
||||||
|
*.js text eol=lf
|
||||||
|
*.svg text eol=lf
|
||||||
|
|
||||||
|
# Docs / data
|
||||||
|
*.md text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
|
||||||
|
# Shell scripts
|
||||||
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
# Binaries
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.zip binary
|
||||||
|
*.7z binary
|
||||||
|
*.exe binary
|
||||||
|
*.dll binary
|
||||||
|
*.pdb binary
|
||||||
|
*.snk binary
|
||||||
|
*.pfx binary
|
||||||
|
|
||||||
|
# Vendored third-party sources: keep byte-identical to upstream, no EOL normalization.
|
||||||
|
# Six of the PdfSharpCore files are UTF-16LE, and the explicit "*.cs text eol=crlf" above
|
||||||
|
# overrides git's binary detection - checkout then injects CR into UTF-16 and misaligns
|
||||||
|
# every code unit after the first newline, so a fresh clone won't compile.
|
||||||
|
third_party/** -text
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: Chocolatey Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Release tag (e.g. v1.4.2)'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
push:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build and push Chocolatey package
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
CHOCO_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }}
|
||||||
|
run: |
|
||||||
|
$releaseTag = if ("${{ github.event_name }}" -eq "workflow_dispatch") { "${{ github.event.inputs.tag }}" } else { "${{ github.event.release.tag_name }}" }
|
||||||
|
$version = $releaseTag.TrimStart('v')
|
||||||
|
$exeUrl = "https://github.com/SteveTheKiller/KillerPDF/releases/download/$releaseTag/KillerPDF.exe"
|
||||||
|
|
||||||
|
# Download release EXE and compute hash
|
||||||
|
Write-Host "Downloading $exeUrl..."
|
||||||
|
Invoke-WebRequest -Uri $exeUrl -OutFile "$env:RUNNER_TEMP\KillerPDF.exe"
|
||||||
|
$hash = (Get-FileHash "$env:RUNNER_TEMP\KillerPDF.exe" -Algorithm SHA256).Hash
|
||||||
|
Write-Host "SHA256: $hash"
|
||||||
|
|
||||||
|
# Stamp version and hash into package files
|
||||||
|
$nuspec = "choco\killerpdf.nuspec"
|
||||||
|
$install = "choco\tools\chocolateyInstall.ps1"
|
||||||
|
|
||||||
|
(Get-Content $nuspec) -replace 'REPLACE_VERSION', $version | Set-Content $nuspec
|
||||||
|
(Get-Content $install) -replace 'REPLACE_HASH', $hash | Set-Content $install
|
||||||
|
|
||||||
|
# Pack and push
|
||||||
|
choco pack choco\killerpdf.nuspec --out "$env:RUNNER_TEMP"
|
||||||
|
choco push "$env:RUNNER_TEMP\killerpdf.$version.nupkg" --source https://push.chocolatey.org --api-key $env:CHOCO_API_KEY
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: WinGet Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Release tag (e.g. v1.4.1)'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
submit:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Install Komac
|
||||||
|
run: |
|
||||||
|
$tag = (Invoke-RestMethod "https://api.github.com/repos/russellbanks/Komac/releases/latest").tag_name
|
||||||
|
$ver = $tag.TrimStart('v')
|
||||||
|
$url = "https://github.com/russellbanks/Komac/releases/download/$tag/komac-$ver-x86_64-pc-windows-msvc.exe"
|
||||||
|
Invoke-WebRequest -Uri $url -OutFile "$env:RUNNER_TEMP\komac.exe"
|
||||||
|
Add-Content $env:GITHUB_PATH $env:RUNNER_TEMP
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Submit to WinGet
|
||||||
|
run: |
|
||||||
|
$releaseTag = if ("${{ github.event_name }}" -eq "workflow_dispatch") { "${{ github.event.inputs.tag }}" } else { "${{ github.event.release.tag_name }}" }
|
||||||
|
$version = $releaseTag.TrimStart('v')
|
||||||
|
$url = "https://github.com/SteveTheKiller/KillerPDF/releases/download/$releaseTag/KillerPDF.exe"
|
||||||
|
komac update SteveTheKiller.KillerPDF --version $version --urls $url --submit
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.WINGET_TOKEN }}
|
||||||
|
shell: pwsh
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
*.user
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
<Application x:Class="KillerPDF.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:local="clr-namespace:KillerPDF">
|
||||||
|
<Application.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<!-- [0] Theme (overridden at startup by ThemeManager.Initialize) -->
|
||||||
|
<ResourceDictionary Source="Themes/Dark.xaml"/>
|
||||||
|
<!-- [1] Strings (overridden at startup by LocaleManager.Initialize) -->
|
||||||
|
<ResourceDictionary Source="Strings/en-US.xaml"/>
|
||||||
|
<!-- NOTHING GOES HERE. Slot [2] belongs to LocaleManager (the locale override,
|
||||||
|
removed entirely for English), and [0]/[1] are the theme and the en-US base.
|
||||||
|
All three are addressed BY INDEX, so an extra dictionary in this collection
|
||||||
|
is silently overwritten or deleted at startup. Window-scoped resources are
|
||||||
|
the place for anything else - see Controls/FileDialog.xaml. -->
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
|
||||||
|
<!-- Chrome close-button corner. Defined at app scope (not just on MainWindow) so the dialog
|
||||||
|
windows that borrow the ChromeCloseButton style can resolve it too - otherwise the
|
||||||
|
DynamicResource is unfound inside a dialog and the red hover box renders square. -->
|
||||||
|
<CornerRadius x:Key="ChromeCloseCorner">0,7,0,0</CornerRadius>
|
||||||
|
|
||||||
|
<!-- Themed slider (size/opacity bars): groove + thumb that match the scrollbar palette.
|
||||||
|
OverridesDefaultStyle so none of WPF's default blue track/selection can show through.
|
||||||
|
App scope for the same reason as ChromeCloseCorner: the Transform and Stamp dialogs
|
||||||
|
apply DarkSlider, and its template resolves the thumb/repeat styles by DynamicResource
|
||||||
|
from THEIR windows - window-scoped styles were unfound there, so the repeat buttons and
|
||||||
|
thumb fell back to stock WPF chrome behind the themed track. -->
|
||||||
|
<Style x:Key="DarkSliderRepeat" TargetType="RepeatButton">
|
||||||
|
<Setter Property="OverridesDefaultStyle" Value="True"/>
|
||||||
|
<Setter Property="Focusable" Value="False"/>
|
||||||
|
<Setter Property="IsTabStop" Value="False"/>
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="RepeatButton">
|
||||||
|
<Border Background="Transparent"/>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="DarkSliderThumb" TargetType="Thumb">
|
||||||
|
<Setter Property="OverridesDefaultStyle" Value="True"/>
|
||||||
|
<Setter Property="Width" Value="12"/>
|
||||||
|
<Setter Property="Height" Value="14"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Thumb">
|
||||||
|
<!-- Solid at rest (the scrollbar's active color) so it's always visible,
|
||||||
|
brightening to the accent while hovered/dragged. -->
|
||||||
|
<Border x:Name="tb" Background="{DynamicResource ScrollThumbHoverBrush}" CornerRadius="3"/>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="tb" Property="Background" Value="{DynamicResource PrimaryBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsDragging" Value="True">
|
||||||
|
<Setter TargetName="tb" Property="Background" Value="{DynamicResource PrimaryBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="DarkSlider" TargetType="Slider">
|
||||||
|
<Setter Property="OverridesDefaultStyle" Value="True"/>
|
||||||
|
<Setter Property="Focusable" Value="False"/>
|
||||||
|
<Setter Property="Height" Value="20"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Slider">
|
||||||
|
<Grid VerticalAlignment="Center" Background="Transparent">
|
||||||
|
<Border Height="4" CornerRadius="2" VerticalAlignment="Center"
|
||||||
|
Background="{DynamicResource SliderTrack}"/>
|
||||||
|
<Track x:Name="PART_Track">
|
||||||
|
<Track.DecreaseRepeatButton>
|
||||||
|
<RepeatButton Style="{DynamicResource DarkSliderRepeat}" Command="{x:Static Slider.DecreaseLarge}"/>
|
||||||
|
</Track.DecreaseRepeatButton>
|
||||||
|
<Track.Thumb>
|
||||||
|
<Thumb Style="{DynamicResource DarkSliderThumb}"/>
|
||||||
|
</Track.Thumb>
|
||||||
|
<Track.IncreaseRepeatButton>
|
||||||
|
<RepeatButton Style="{DynamicResource DarkSliderRepeat}" Command="{x:Static Slider.IncreaseLarge}"/>
|
||||||
|
</Track.IncreaseRepeatButton>
|
||||||
|
</Track>
|
||||||
|
</Grid>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================================
|
||||||
|
PRODUCT-SPECIFIC DESIGN TOKENS. Shared trademark palette tokens come from KillerUI;
|
||||||
|
these local resources cover KillerPDF-only tools and dialogs.
|
||||||
|
Theme COLORS live in Themes/*.xaml; these are the theme-independent type,
|
||||||
|
shape, and elevation primitives. Code-built UI reads the same keys through
|
||||||
|
UiKit (UiKit.cs), so XAML and code can never drift apart.
|
||||||
|
============================================================================ -->
|
||||||
|
|
||||||
|
<!-- Type families -->
|
||||||
|
<FontFamily x:Key="UiFont">Segoe UI, Microsoft JhengHei UI, Nirmala UI</FontFamily>
|
||||||
|
<!-- The full embedded Typewriter a602 face used by the KillerPDF wordmark. Keep the
|
||||||
|
internal family name in sync with the font's name table; an approximate name makes
|
||||||
|
WPF silently substitute the system font. -->
|
||||||
|
<!-- Assembly-relative: release payloads run as KillerPDF.App.exe while legacy and
|
||||||
|
IDE builds run as KillerPDF.exe. Hard-coding either assembly name makes the
|
||||||
|
embedded typewriter face silently fall back in the other build. -->
|
||||||
|
<FontFamily x:Key="WordmarkFont">./Fonts/#Typewriter - a602 (dead postman 2004)</FontFamily>
|
||||||
|
<FontFamily x:Key="WordmarkFontPdf">./Fonts/#Typewriter - a602 (dead postman 2004)</FontFamily>
|
||||||
|
<FontFamily x:Key="MonoFont">Consolas</FontFamily>
|
||||||
|
<FontFamily x:Key="IconFont">Segoe MDL2 Assets</FontFamily>
|
||||||
|
|
||||||
|
<!-- Corner radii (one rounding scale for the whole app) -->
|
||||||
|
<CornerRadius x:Key="RadControl">3</CornerRadius>
|
||||||
|
<CornerRadius x:Key="RadCard">6</CornerRadius>
|
||||||
|
<CornerRadius x:Key="RadWindow">7</CornerRadius>
|
||||||
|
|
||||||
|
<!-- Shared document/panel recess. Modern themes give these rings transparent brushes
|
||||||
|
and zero thickness; 98SE supplies the full outer + inner directional bevel. -->
|
||||||
|
<Style x:Key="PaneBevelOverlay" TargetType="{x:Type Control}">
|
||||||
|
<Setter Property="IsHitTestVisible" Value="False"/>
|
||||||
|
<Setter Property="Focusable" Value="False"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="{x:Type Control}">
|
||||||
|
<Grid>
|
||||||
|
<Border BorderBrush="{DynamicResource PaneBevelDarkBrush}"
|
||||||
|
BorderThickness="{DynamicResource PaneBevelLightThickness}"/>
|
||||||
|
<Border BorderBrush="{DynamicResource PaneBevelLightBrush}"
|
||||||
|
BorderThickness="{DynamicResource PaneBevelDarkThickness}"/>
|
||||||
|
<Border Margin="{DynamicResource PaneBevelInnerMargin}"
|
||||||
|
BorderBrush="{DynamicResource PaneBevelDark2Brush}"
|
||||||
|
BorderThickness="{DynamicResource PaneBevel2LightThickness}"/>
|
||||||
|
<Border Margin="{DynamicResource PaneBevelInnerMargin}"
|
||||||
|
BorderBrush="{DynamicResource PaneBevelLight2Brush}"
|
||||||
|
BorderThickness="{DynamicResource PaneBevel2DarkThickness}"/>
|
||||||
|
</Grid>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Elevation: a single shared instance per shadow can be applied to many elements -->
|
||||||
|
<DropShadowEffect x:Key="ShadowText" Color="Black" BlurRadius="3" ShadowDepth="1" Direction="270" Opacity="0.6"/>
|
||||||
|
<DropShadowEffect x:Key="ShadowIcon" Color="Black" BlurRadius="4" ShadowDepth="1" Direction="270" Opacity="0.9"/>
|
||||||
|
<DropShadowEffect x:Key="ShadowBar" Color="Black" BlurRadius="6" ShadowDepth="3" Direction="270" Opacity="{DynamicResource BarShadowOpacity}"/>
|
||||||
|
<!-- Quality for the same reason as PaneShadow below: at radius 18 the default
|
||||||
|
Performance bias blocks up visibly on a dialog's rounded corners. -->
|
||||||
|
<DropShadowEffect x:Key="ShadowDialog" Color="Black" BlurRadius="18" ShadowDepth="3" Direction="270" Opacity="0.6"
|
||||||
|
RenderingBias="Quality"/>
|
||||||
|
<!-- Elevation for the content pane. Two hard-won constraints below, both measured on
|
||||||
|
the running window rather than reasoned about - every attempt to reason this one
|
||||||
|
out was wrong. Read both before changing any number here.
|
||||||
|
BLUR MUST NOT EXCEED ~2x THE GUTTER WIDTH. A DropShadowEffect spreads about half
|
||||||
|
its BlurRadius laterally, the gutter is 8px, and anything past that gets truncated
|
||||||
|
by the window frame mid-falloff and terminates as a step - which is a hard edge no
|
||||||
|
amount of RenderingBias fixes. Measured at blur 22: across the gutter the shadow
|
||||||
|
ran 139 / 157 / 174 / 188 against an unshadowed 220, so it was still at ~85% of
|
||||||
|
its travel when the window edge cut it off. Blur 16 (the family value) completes
|
||||||
|
the falloff inside 8px. If the gutter is ever widened, the blur can grow with it.
|
||||||
|
Opacity stays at 0.78 rather than the family 0.6: KillerPDF's card is usually
|
||||||
|
filled edge to edge by a WHITE page, so the boundary is a near-full-range value
|
||||||
|
jump and the family opacity is swallowed by it - the right gutter needs more
|
||||||
|
depth, and a lower opacity left a hard edge on the gradient. (2026-07-31) -->
|
||||||
|
<!-- ShadowDepth 0, deliberately, and this is the ONE place in the family that wants it.
|
||||||
|
MEASURED on the running window, sampling the 8px gutter down the right edge: with
|
||||||
|
depth 2 the gutter was still light at 3px below the card's top border and only
|
||||||
|
reached full shadow ~12px down. Any non-zero depth offsets the whole cast, so the
|
||||||
|
lateral shadow is missing at the top and ramps in as it descends - and that onset
|
||||||
|
is what reads as a hard edge at the corner. Going 5 -> 2 only shortened the ramp.
|
||||||
|
Zero makes it a symmetric halo, which wraps the rounded corners evenly.
|
||||||
|
The family rule against depth 0 ("a halo with nothing below to land on") was about
|
||||||
|
the FOOTER cast, and no longer applies here: the footer is Panel.ZIndex -1 now, so
|
||||||
|
it sits under the card and takes the shadow, and the blur alone puts plenty on it
|
||||||
|
without an offset. Verify that still holds if the blur is reduced further.
|
||||||
|
The requirement: the drop shadow has to run under the rounded edge smoothly, with
|
||||||
|
no hard edge left in the gradient. (2026-07-31) -->
|
||||||
|
<!-- RenderingBias Quality is REQUIRED here, not a nicety. The default (Performance)
|
||||||
|
renders the blur into a reduced-resolution intermediate and scales it back up, so
|
||||||
|
the falloff comes out as chunky stair-stepped blocks - worst exactly at a rounded
|
||||||
|
corner, where the curve makes the downsampling obvious. It scales with BlurRadius,
|
||||||
|
and showed up here while this was briefly at 22. Any large-radius shadow in the
|
||||||
|
family needs it; the small ones (ShadowText/Icon/Bar, radius 3-6) are under the
|
||||||
|
threshold where it reads, and Quality costs real render time.
|
||||||
|
The corner shadows showed hard edges, caught at 491% in GIMP. (2026-07-31) -->
|
||||||
|
<!-- Family standard (KillerShell reference): BlurRadius 16, ShadowDepth 5, Direction 270 -
|
||||||
|
a downward cast over the footer, not an even halo. -->
|
||||||
|
<DropShadowEffect x:Key="PaneShadow" Color="Black" BlurRadius="16" ShadowDepth="5" Direction="270" Opacity="{DynamicResource PaneShadowOpacity}"
|
||||||
|
RenderingBias="Quality"/>
|
||||||
|
|
||||||
|
<!-- GrainBrushShared deliberately does NOT live here - see MainWindow.Resources.
|
||||||
|
WPF freezes Freezables in APPLICATION-level dictionaries for cross-thread safety,
|
||||||
|
so an ImageBrush declared here throws "Cannot set a property ... it is in a
|
||||||
|
read-only state" the moment ApplyGrainTexture assigns its ImageSource. Any brush
|
||||||
|
whose properties are written at runtime must stay at window scope. -->
|
||||||
|
|
||||||
|
<!-- Global themed tooltip (replaces the plain white system box) -->
|
||||||
|
<Style TargetType="ToolTip">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource MenuBackgroundBrush}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource MenuBorderBrush}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<Setter Property="Padding" Value="8,4"/>
|
||||||
|
<Setter Property="FontFamily" Value="Segoe UI, Microsoft JhengHei UI, Nirmala UI"/>
|
||||||
|
<Setter Property="FontSize" Value="12"/>
|
||||||
|
<Setter Property="HasDropShadow" Value="False"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ToolTip">
|
||||||
|
<Border Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
CornerRadius="4"
|
||||||
|
Padding="{TemplateBinding Padding}">
|
||||||
|
<Border.Effect>
|
||||||
|
<DropShadowEffect Color="Black" BlurRadius="8" ShadowDepth="2" Direction="270" Opacity="0.45"/>
|
||||||
|
</Border.Effect>
|
||||||
|
<ContentPresenter/>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Thin themed scrollbar. App scope (not just MainWindow) so every dialog window inherits
|
||||||
|
it instead of falling back to the default gray system scrollbar. The thumb colors come
|
||||||
|
from the active theme (BgScrollThumb / ScrollThumbHover). -->
|
||||||
|
<local:ThumbViewportFloorConverter x:Key="ThumbFloor"/>
|
||||||
|
<Style TargetType="ScrollBar">
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="Width" Value="{DynamicResource ScrollBarThickness}"/>
|
||||||
|
<Setter Property="MinWidth" Value="{DynamicResource ScrollBarThickness}"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ScrollBar">
|
||||||
|
<DockPanel LastChildFill="True">
|
||||||
|
<RepeatButton x:Name="LineUp" DockPanel.Dock="Top" Command="ScrollBar.LineUpCommand"
|
||||||
|
Content="0" Focusable="False" IsTabStop="False"
|
||||||
|
Width="{DynamicResource ScrollArrowSize}" Height="{DynamicResource ScrollArrowSize}">
|
||||||
|
<RepeatButton.Template>
|
||||||
|
<ControlTemplate TargetType="RepeatButton">
|
||||||
|
<Grid>
|
||||||
|
<Border Background="{DynamicResource CaptionButtonBrush}"/>
|
||||||
|
<Border x:Name="b1" Margin="{DynamicResource ScrollArrowTopBevelMargin}"
|
||||||
|
BorderBrush="{DynamicResource BevelLightBrush}" BorderThickness="{DynamicResource BevelLightThickness}"/>
|
||||||
|
<Border x:Name="b2" Margin="{DynamicResource ScrollArrowTopBevelMargin}"
|
||||||
|
BorderBrush="{DynamicResource BevelDarkBrush}" BorderThickness="{DynamicResource BevelDarkThickness}"/>
|
||||||
|
<Path Data="M 0,4 L 3.5,0 L 7,4 Z" Width="7" Height="4" Stretch="None"
|
||||||
|
Fill="{DynamicResource CaptionGlyphBrush}" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
RenderTransformOrigin="0.5,0.5">
|
||||||
|
<Path.RenderTransform><RotateTransform Angle="{Binding Content, RelativeSource={RelativeSource TemplatedParent}}"/></Path.RenderTransform>
|
||||||
|
</Path>
|
||||||
|
</Grid>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsPressed" Value="True">
|
||||||
|
<Setter TargetName="b1" Property="BorderBrush" Value="{DynamicResource BevelDarkBrush}"/>
|
||||||
|
<Setter TargetName="b2" Property="BorderBrush" Value="{DynamicResource BevelLightBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</RepeatButton.Template>
|
||||||
|
</RepeatButton>
|
||||||
|
<RepeatButton x:Name="LineDown" DockPanel.Dock="Bottom" Command="ScrollBar.LineDownCommand"
|
||||||
|
Content="180" Focusable="False" IsTabStop="False"
|
||||||
|
Width="{DynamicResource ScrollArrowSize}" Height="{DynamicResource ScrollArrowSize}">
|
||||||
|
<RepeatButton.Template>
|
||||||
|
<ControlTemplate TargetType="RepeatButton">
|
||||||
|
<Grid>
|
||||||
|
<Border Background="{DynamicResource CaptionButtonBrush}"/>
|
||||||
|
<Border x:Name="b1" BorderBrush="{DynamicResource BevelLightBrush}" BorderThickness="{DynamicResource BevelLightThickness}"/>
|
||||||
|
<Border x:Name="b2" BorderBrush="{DynamicResource BevelDarkBrush}" BorderThickness="{DynamicResource BevelDarkThickness}"/>
|
||||||
|
<Path Data="M 0,4 L 3.5,0 L 7,4 Z" Width="7" Height="4" Stretch="None"
|
||||||
|
Fill="{DynamicResource CaptionGlyphBrush}" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
RenderTransformOrigin="0.5,0.5">
|
||||||
|
<Path.RenderTransform><RotateTransform Angle="{Binding Content, RelativeSource={RelativeSource TemplatedParent}}"/></Path.RenderTransform>
|
||||||
|
</Path>
|
||||||
|
</Grid>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsPressed" Value="True">
|
||||||
|
<Setter TargetName="b1" Property="BorderBrush" Value="{DynamicResource BevelDarkBrush}"/>
|
||||||
|
<Setter TargetName="b2" Property="BorderBrush" Value="{DynamicResource BevelLightBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</RepeatButton.Template>
|
||||||
|
</RepeatButton>
|
||||||
|
<Grid>
|
||||||
|
<Border Background="{DynamicResource ScrollTrackBrush}"/>
|
||||||
|
<Border BorderBrush="{DynamicResource ScrollTrackBevelDark}" BorderThickness="{DynamicResource BevelLightThickness}"/>
|
||||||
|
<Border BorderBrush="{DynamicResource ScrollTrackBevelLight}" BorderThickness="{DynamicResource BevelDarkThickness}"/>
|
||||||
|
<Track x:Name="PART_Track" IsDirectionReversed="True"
|
||||||
|
Minimum="{TemplateBinding Minimum}"
|
||||||
|
Maximum="{TemplateBinding Maximum}"
|
||||||
|
Value="{TemplateBinding Value}">
|
||||||
|
<!-- Reactive thumb with a minimum-length floor. ViewportSize is fed through
|
||||||
|
ThumbViewportFloorConverter, which raises it just enough that the Track's
|
||||||
|
proportional thumb never falls below the floor. -->
|
||||||
|
<Track.ViewportSize>
|
||||||
|
<MultiBinding Converter="{StaticResource ThumbFloor}" ConverterParameter="64">
|
||||||
|
<Binding RelativeSource="{RelativeSource TemplatedParent}" Path="ViewportSize"/>
|
||||||
|
<Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Maximum"/>
|
||||||
|
<Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Minimum"/>
|
||||||
|
<Binding RelativeSource="{RelativeSource TemplatedParent}" Path="ActualWidth"/>
|
||||||
|
<Binding RelativeSource="{RelativeSource TemplatedParent}" Path="ActualHeight"/>
|
||||||
|
<Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Orientation"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</Track.ViewportSize>
|
||||||
|
<Track.DecreaseRepeatButton>
|
||||||
|
<RepeatButton x:Name="PageBack" Command="ScrollBar.PageUpCommand" Focusable="False" IsTabStop="False">
|
||||||
|
<RepeatButton.Template>
|
||||||
|
<ControlTemplate TargetType="RepeatButton"><Border Background="Transparent"/></ControlTemplate>
|
||||||
|
</RepeatButton.Template>
|
||||||
|
</RepeatButton>
|
||||||
|
</Track.DecreaseRepeatButton>
|
||||||
|
<Track.IncreaseRepeatButton>
|
||||||
|
<RepeatButton x:Name="PageForward" Command="ScrollBar.PageDownCommand" Focusable="False" IsTabStop="False">
|
||||||
|
<RepeatButton.Template>
|
||||||
|
<ControlTemplate TargetType="RepeatButton"><Border Background="Transparent"/></ControlTemplate>
|
||||||
|
</RepeatButton.Template>
|
||||||
|
</RepeatButton>
|
||||||
|
</Track.IncreaseRepeatButton>
|
||||||
|
<Track.Thumb>
|
||||||
|
<Thumb x:Name="ScrollThumb">
|
||||||
|
<Thumb.Style>
|
||||||
|
<Style TargetType="Thumb">
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Thumb">
|
||||||
|
<Grid x:Name="thumbBorder" Margin="{DynamicResource ScrollThumbMargin}">
|
||||||
|
<Border x:Name="thumbFill" Background="{DynamicResource ScrollThumbBrush}"
|
||||||
|
CornerRadius="{DynamicResource ScrollThumbRadius}"/>
|
||||||
|
<Border BorderBrush="{DynamicResource BevelLightBrush}" BorderThickness="{DynamicResource BevelLightThickness}"/>
|
||||||
|
<Border BorderBrush="{DynamicResource BevelDarkBrush}" BorderThickness="{DynamicResource BevelDarkThickness}"/>
|
||||||
|
</Grid>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<!-- Horizontal bar: keep the thumb thin on the vertical axis instead -->
|
||||||
|
<DataTrigger Binding="{Binding Orientation, RelativeSource={RelativeSource AncestorType={x:Type ScrollBar}}}" Value="Horizontal">
|
||||||
|
<Setter TargetName="thumbBorder" Property="Margin" Value="0,4"/>
|
||||||
|
</DataTrigger>
|
||||||
|
<!-- Highlight + fatten while hovering the thumb itself (keeps the press/drag reliable;
|
||||||
|
binding this to the whole bar's IsMouseOver made the thumb shrink on mouse-down and
|
||||||
|
dropped the grab). -->
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="thumbFill" Property="Background" Value="{DynamicResource ScrollThumbHoverBrush}"/>
|
||||||
|
<Setter TargetName="thumbBorder" Property="Margin" Value="2,2"/>
|
||||||
|
<Setter TargetName="thumbBorder" Property="Effect">
|
||||||
|
<Setter.Value>
|
||||||
|
<DropShadowEffect Color="Black" BlurRadius="4" ShadowDepth="0" Opacity="0.3"/>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</Thumb.Style>
|
||||||
|
</Thumb>
|
||||||
|
</Track.Thumb>
|
||||||
|
</Track>
|
||||||
|
</Grid>
|
||||||
|
</DockPanel>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<!-- A vertical track counts downward, so it needs IsDirectionReversed;
|
||||||
|
a horizontal one counts rightward and must not, or dragging the thumb
|
||||||
|
left scrolls the content right. The paging commands flip with it. -->
|
||||||
|
<Trigger Property="Orientation" Value="Horizontal">
|
||||||
|
<Setter TargetName="PART_Track" Property="IsDirectionReversed" Value="False"/>
|
||||||
|
<Setter TargetName="PageBack" Property="Command" Value="ScrollBar.PageLeftCommand"/>
|
||||||
|
<Setter TargetName="PageForward" Property="Command" Value="ScrollBar.PageRightCommand"/>
|
||||||
|
<Setter TargetName="LineUp" Property="DockPanel.Dock" Value="Left"/>
|
||||||
|
<Setter TargetName="LineDown" Property="DockPanel.Dock" Value="Right"/>
|
||||||
|
<Setter TargetName="LineUp" Property="Command" Value="ScrollBar.LineLeftCommand"/>
|
||||||
|
<Setter TargetName="LineDown" Property="Command" Value="ScrollBar.LineRightCommand"/>
|
||||||
|
<Setter TargetName="LineUp" Property="Content" Value="270"/>
|
||||||
|
<Setter TargetName="LineDown" Property="Content" Value="90"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="Orientation" Value="Horizontal">
|
||||||
|
<Setter Property="Height" Value="{DynamicResource ScrollBarThickness}"/>
|
||||||
|
<Setter Property="MinHeight" Value="{DynamicResource ScrollBarThickness}"/>
|
||||||
|
<Setter Property="Width" Value="Auto"/>
|
||||||
|
</Trigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
+1936
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
[assembly: ThemeInfo(
|
||||||
|
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
|
||||||
|
//(used if a resource is not found in the page,
|
||||||
|
// or application resource dictionaries)
|
||||||
|
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
|
||||||
|
//(used if a resource is not found in the page,
|
||||||
|
// app, or any theme specific resource dictionaries)
|
||||||
|
)]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Build-time constants written or verified by release.ps1.
|
||||||
|
/// </summary>
|
||||||
|
internal static class BuildInfo
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// SHA256 of pdfium.dll (original bytes, before Costura compression).
|
||||||
|
/// Updated by release.ps1 immediately before each build.
|
||||||
|
/// All-zeros means the check is disabled (dev / SkipSign builds).
|
||||||
|
/// </summary>
|
||||||
|
internal const string PdfiumSha256 = "BCA96944D731DD72877116D3472083C847FE307FC58CA39BCE16CBE998C478F1";
|
||||||
|
|
||||||
|
internal const string PdfiumSha256Disabled = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||||
|
}
|
||||||
|
}
|
||||||
+580
@@ -0,0 +1,580 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to KillerPDF are documented here.
|
||||||
|
|
||||||
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.7.5] - 2026-08-22
|
||||||
|
|
||||||
|
KillerPDF 1.7.5 is a small maintenance release that closes several visible annotation, scrolling, shortcut, theme, and localization regressions. It keeps the faster scrolling introduced in 1.7.4, makes Transform trustworthy with freshly placed text, and gives the text annotation toolbar a cleaner two-row layout.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Shift+mouse wheel now scrolls wide pages horizontally, using the same scrolling path as a tilt wheel (#209, thanks Ryokoxx).
|
||||||
|
- Ctrl+B, Ctrl+I and Ctrl+U now bold, italicize and underline while you are editing a text box. They were listed in the shortcuts for weeks without ever being wired up.
|
||||||
|
- Hungarian OCR completes the twelve-language OCR catalog, so every language available for the KillerPDF interface now has a matching downloadable recognition model.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The text annotation toolbar now uses a deliberate two-row layout: font above size, text color above fill color, and text opacity above fill opacity. It is taller but substantially narrower, with each lower control aligned beneath its corresponding upper control instead of leaving Fill Opacity stranded on an accidental wrapped row.
|
||||||
|
- The sidebar moved from Ctrl+B to F9, and moving it left or right from Ctrl+Shift+B to Shift+F9. Ctrl+B was documented as bold and as the sidebar at the same time, and it was the sidebar that answered. F9 was the one function key with nothing of its own to do: the four view modes still have F5 to F8, and the wheel over the view still cycles them.
|
||||||
|
- The current-page span badge now casts a small shadow beneath its rectangle, while its text remains independently rendered and crisp. The 98SE theme keeps the badge flat with the rest of its classic chrome.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Rotating a page now keeps upright text boxes, images, and signatures inside the new page bounds. Their centers still follow the rotated sheet, but an item near the old long edge is clamped before it can become invisible and unrecoverable off-page (#169, thanks terada-d).
|
||||||
|
- Fast wheel scrolling in Single Page and Two-Page views no longer carries its remaining momentum into an accidental page change at the edge. Scrolling keeps its existing speed; changing pages requires a deliberate second wheel gesture (#205, thanks 1mk3r).
|
||||||
|
- Transform now commits an active text box before building its preview, so text placed immediately before opening Transform is included in both the preview and the transformed page.
|
||||||
|
- Grid zoom now updates every page seam in one layout pass, so the pages no longer resize first and then visibly settle one border at a time as their refreshed bitmaps arrive.
|
||||||
|
- Switching themes, accents, or languages with an annotation or crop bar open now rebuilds that bar completely in both split panes. This fixes controls retaining colors from the previous theme, including the light-theme mismatch, and keeps code-built crop labels, tooltips, and buttons current without reopening the tool.
|
||||||
|
- Nine dialogs, including the install and update prompts, now preserve their intended line breaks. The strings carried the breaks but not the attribute that stops XAML collapsing them, so adding more had no effect (#231, thanks bovirus).
|
||||||
|
- Shortcut key and mouse names, including Ctrl, Shift, Home, End, Delete, Click and Scroll, are now translatable in all twelve languages. The list and visual keyboard are generated from one shared table, fixing the missing Alt+M entry and inconsistent navigation descriptions while preventing the two views from drifting again (#230, thanks bovirus).
|
||||||
|
- Shared dialog buttons now translate OK, Cancel, Yes, and No, including the custom color picker (#227, thanks Mr-Update).
|
||||||
|
- Recent files now translate the `missing` label instead of leaving it in English (#227, thanks Mr-Update).
|
||||||
|
- Annotation copy, paste, and delete confirmations now use the active language and the correct singular or plural message (#227, thanks Mr-Update).
|
||||||
|
- Search now translates its empty, error, summary, navigation, and close messages. Its result field is wider so longer translated states are not clipped (#227, thanks Mr-Update).
|
||||||
|
- OCR model downloads now translate their progress and cancellation hints, including multi-model downloads, and flatten/export progress is translated as well (#227, thanks Mr-Update).
|
||||||
|
- The portable launcher now publishes cleanly with the .NET 10 SDK without trying to copy an unused binding-redirect configuration file.
|
||||||
|
|
||||||
|
## [1.7.4] - 2026-08-21
|
||||||
|
|
||||||
|
KillerPDF 1.7.4 keeps the convenience of one portable download while installing as a normal multi-file application, cutting initial startup time substantially. This release also fixes annotation rotation, form fields on comma-decimal locales, installation scope, and a range of viewer, dialog, and localization problems. Hungarian localization and page image export are included as well.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- "Export page as image" on the Pages panel's right-click menu, including multi-page selections (#207, thanks 1mk3r).
|
||||||
|
- Hungarian (hu-HU) localization, the twelfth interface language, in the language picker as "Magyar" (PR #214, thanks CsokiHUN).
|
||||||
|
- Hide the toolbar from its right-click menu or Alt+M, and full screen no longer sits over other applications when you switch away (#215, thanks Subjuntivos).
|
||||||
|
- Translations can be tested in a normal install and reload on every save of the file; TRANSLATING.md has the steps (#211, thanks bovirus).
|
||||||
|
- The page badge fires on grid scrolling and names the visible span (#197, thanks Ryokoxx).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- KillerPDF now remains one portable download while installing as a normal multi-file application. The portable EXE carries one compressed, verified payload and cleans up its temporary files after use; installed shortcuts launch the inner app directly, avoiding Costura extraction and reducing measured first startup by about 40% on the development machine (#189, thanks ags1234). The new package is also roughly 34% smaller than the previous woven EXE.
|
||||||
|
- Builds no longer risk the net48 CS8336 attribute collision introduced by compiler-generated polyfills (PR #218, thanks Ryokoxx).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Printing now composes and spools on a dedicated thread, keeping the progress window responsive throughout large jobs (PR #228, thanks Ryokoxx). Print layout choices are frozen when the job begins so keyboard input during preparation cannot change N-up grouping or skip or duplicate pages.
|
||||||
|
- Rotating a page no longer deletes the document's unsaved annotations; they now turn with the page (#169, thanks terada-d).
|
||||||
|
- Form fields saved on systems whose decimal separator is a comma (German and most European locales) now get valid appearance streams; they previously came out blank or garbled with repeated, re-wrapped text in other viewers and in print, flatten, and export, thanks Thomas.
|
||||||
|
- Print, flatten, image export, and thumbnails no longer draw a form field twice when its stored appearance disagrees with the regenerated one.
|
||||||
|
- Installation scope is now guarded end to end: a per-user install cannot sit beside an all-users install, existing dual installs are detected with an offer to remove the inactive copy, converting to all-users removes the older per-user copy, and machine-wide uninstall requests administrator access instead of reporting success after permission failures.
|
||||||
|
- The Open dialog no longer crashes where Explorer's Quick Access cannot be read, such as under Wine and CrossOver; the pinned folders and drives still list (#210, thanks Ximelay).
|
||||||
|
- Opening a PDF from Explorer while KillerPDF is still starting no longer crashes; the file now opens once the window is ready (#202, thanks tgv123456).
|
||||||
|
- Dropping a damaged PDF on the Pages panel now offers the same repair the Open dialog offers, instead of silently ignoring the file (#203, thanks 1mk3r).
|
||||||
|
- After an install relaunch or split-pane session restore, the sidebar now attaches the active pane's thumbnail cache before the first visible frame instead of remaining blank until the user clicks a pane.
|
||||||
|
- Snapping, maximizing, or restoring the window keeps the split panes' proportions, and a sidebar you closed stays closed when a tab loads its document.
|
||||||
|
- Grid view no longer drops its last column into the next row at certain pane widths.
|
||||||
|
- In grid view, drawing on a page or clicking one of its annotations now selects that page, as a plain click already did.
|
||||||
|
- Image pickers (Insert Image, image signatures and stamps, watermark) now return to the last folder an image was picked from, instead of wherever the last PDF was opened.
|
||||||
|
- Dragging the title bar downward restores a maximized window from anywhere along the bar, including over the logo, on every theme (#206, thanks 1mk3r).
|
||||||
|
- The page list's top and bottom edge fades are restored on every theme except 98SE, which deliberately has none. Switching away from 98SE now explicitly restores them instead of carrying its zero-opacity setting into the next theme.
|
||||||
|
- The empty-state recent-files panel now responds to ordinary window resizing, hiding before it crowds the drop target and returning when the pane has enough room.
|
||||||
|
- The theme and language tooltips are no longer all caps, the VIEW shortcut category matches the other headings, and the zoom shortcuts read Ctrl++ instead of Ctrl+=, in every language (PR #216, thanks Mr-Update).
|
||||||
|
- The "Show current file size" shortcut description is translated in every language (#217, thanks Mr-Update).
|
||||||
|
- Unsigned local development packages can now exercise the complete install path, while public release launchers retain a non-bypassable digital-signature requirement.
|
||||||
|
- The hardcoded English strings identified during 1.7.4 development were translated in all twelve languages, including dialog titles, file-picker filters, error and confirmation dialogs, status messages, busy overlays, and the default DRAFT watermark text. Polish also gained the seven newest theme names (#227, thanks Mr-Update).
|
||||||
|
- The annotate settings bars (text, draw, highlight, line, shape) now reflow in single-row groups on a narrow window or split pane; anything that would need a third row collapses into an overflow chevron, least-used controls first.
|
||||||
|
- A render failure partway through streaming grid tiles no longer strands the remaining pages blank; the failed page is skipped and the stream retries once.
|
||||||
|
- Grid view opened in an unfocused split pane now fills the pane width instead of keeping a surround margin and showing a horizontal scrollbar.
|
||||||
|
- A print page range ending in a huge number no longer freezes the app, and a range matching no pages now says so and disables Print instead of spooling the whole document (PRs #222 and #220, thanks Ryokoxx).
|
||||||
|
- Checkbox labels now wrap instead of clipping in languages with longer text, and dropdown lists respect their intended maximum height (PRs #224 and #225, thanks Ryokoxx).
|
||||||
|
- The print preview's scrollbar and chevrons now follow the theme, and visiting the 98SE theme no longer leaves its gray chip color behind on other themes (PR #219, thanks Ryokoxx).
|
||||||
|
- The color picker's OK button is readable at rest on every theme; it previously only showed its label on hover, and its Cancel button and remaining tooltips are now translated (#227, thanks Mr-Update).
|
||||||
|
- On the 98SE theme, the color picker now wears the classic caption bar and raised window frame with beveled buttons, code-built dialogs are square-cornered, and the annotate settings bars dock as flush full-width toolbar bands with the proper 2px bevel instead of floating with a thin misdrawn edge and leftover film grain.
|
||||||
|
|
||||||
|
## [1.7.3] - 2026-08-15
|
||||||
|
|
||||||
|
1.7.3 corrects theme accents and restores the missing visual preview in image-selection dialogs.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The active tab's ring and underline now follow the chosen accent color; they stayed on the theme's base color under any other accent, on every theme.
|
||||||
|
- Image-selection dialogs now include a live preview pane for image import, image signatures, image stamps, and Insert Image.
|
||||||
|
|
||||||
|
## [1.7.2] - 2026-08-15
|
||||||
|
|
||||||
|
KillerPDF 1.7.2 completes the split-pane viewer refactor and builds on it with seven new themes, Polish localization, book layout, Levels, expanded print controls, per-pane night mode, and a substantial round of rendering, memory, form, and interface fixes.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added 98SE, Ectoplasm, Decay, Mourning, Sepulchre, Delirium, and Malaise themes.
|
||||||
|
- The print dialog has paper size and paper source selectors, and its settings are organized into collapsible PRINTER, LAYOUT, and OUTPUT sections (#186, thanks demo1866 and adeit).
|
||||||
|
- Two-Page view has a book layout option: the cover page displays alone, so facing pages pair like a physical book (#193, thanks TeutonJon78).
|
||||||
|
- Comb text fields are supported: typing is capped at the cell count and the saved value places one character per printed box, like Acrobat (#158, thanks flywire).
|
||||||
|
- Clicking the status line shows the open file's size for a moment, then restores what was there.
|
||||||
|
- Text selection follows columns: dragging down one column of a two-column PDF no longer sweeps the neighboring column, and copied text comes out in column order (#185, thanks twtscurry30-ai).
|
||||||
|
- The Transform tool has a LEVELS section with black point, white point, and midtone controls for rescuing pale, hard-to-read scans. It applies the correction like the other Transform options (#174, thanks 1mk3r).
|
||||||
|
- Night-mode invert is per pane in split view: the moon flips only the focused pane, and its rail icon follows pane focus.
|
||||||
|
- Polish (pl-PL) localization, the eleventh interface language, in the language picker as "Polski" (#191, thanks Fresta24).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The page number shows in a corner badge that slides away when the view settles, replacing the tooltip that followed the cursor (#197, thanks Ryokoxx).
|
||||||
|
- The Outlines sidebar opens with top-level bookmarks visible and deeper levels folded, and expand/collapse choices now stick across tab switches and edits instead of re-expanding everything.
|
||||||
|
- Keyboard access and context-menu hints were audited for 1.7.2: the file-size action has Shift+F4, and applicable menus now show icons and shortcuts.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The re-sharpen pass renders at device resolution instead of twice it, sharply cutting memory use on large documents, and re-renders on DPI changes (#189, PR #194, thanks Ryokoxx).
|
||||||
|
- The page bitmap cache is now budgeted in bytes (~160 MB per tab) instead of a fixed page count, cutting the other large share of memory on big documents (#189, thanks ags1234).
|
||||||
|
- Saved highlights now use the Multiply blend mode, darkening the paper behind the text instead of washing the text out with an opaque rectangle (#200, thanks playerbhr).
|
||||||
|
- The picker radio's selected dot is centered in its ring (PR #198, thanks Ryokoxx).
|
||||||
|
- The theme flyout no longer jumps when switching to or from a theme without accent swatches (#199, thanks Ryokoxx).
|
||||||
|
- Reopening a file restores its last manual zoom level (#201, thanks kilasuelika).
|
||||||
|
- Resizing split panes in grid view no longer blanks the grid and rebuilds it page by page: the stretched tiles stay visible and get their bitmaps swapped in place.
|
||||||
|
- Exported images carry the chosen DPI in their metadata instead of always reporting 96, in both the GUI export and the CLI (#188, thanks GruNostalgia).
|
||||||
|
- Dropping PDFs or images onto the Pages sidebar appends their pages to the open document (#172, thanks 1mk3r).
|
||||||
|
- Form field text no longer shows a ghost "shadow" copy behind it: the viewer stopped baking field appearances into the page bitmap underneath the live field overlays, thanks Thomas. Print, flatten, export, and thumbnails still include them.
|
||||||
|
- Rotating a page that was opened with a non-zero /Rotate no longer swaps its MediaBox on save, which permanently clipped the content. Fixed in the vendored PdfSharpCore, whose landscape media-box flip fired on read pages (#184, thanks terada-d).
|
||||||
|
- Documents opened from Explorer get keyboard focus immediately, so arrows and Page Up/Down work without clicking the window first, and horizontal scrolling from a touchpad or tilt wheel now pans the document (#196, thanks Subjuntivos).
|
||||||
|
- Double-click text editing now maps PostScript font names (ArialMT, TimesNewRomanPSMT, Helvetica) to the installed Windows family, so edited text keeps its font instead of falling back to the default (#187, thanks fo-bo).
|
||||||
|
- Machine-wide installs register the killerpdf:// handler for all users, and it now appears in Default apps under link types (#183, thanks adeit).
|
||||||
|
- Themes are entirely owned by the KillerPDF repository again. The project no longer imports a private sibling `KillerUI` folder or overlays its resources at runtime, so a standalone clone contains every theme resource it builds and displays.
|
||||||
|
- Completed the PDF viewer extraction so split panes keep independent documents, tabs, pages, tools, selections, and sidebar positions.
|
||||||
|
- Various UI and theme consistency tweaks, including clearer Black-theme surfaces and controls, consistent floating-bar borders, legible accent buttons, and balanced film grain across the themes.
|
||||||
|
|
||||||
|
## [1.7.1] - 2026-08-04
|
||||||
|
|
||||||
|
1.7.1 fixes the latest reported crashes, rendering problems, installer registration, file navigation, and editing issues, while adding perspective correction and app-link support.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Transform can now correct trapezoidal perspective distortion in pages photographed at an angle. Turn on perspective correction, drag four corner handles onto the photographed page outline, and Apply converts that quadrilateral into a straight rectangular page at the full transform resolution. The correction composes with rotation, deskew, scaling, and flipping in the same operation (#175, thanks 1mk3r).
|
||||||
|
- KillerPDF now registers a `killerpdf://` link handler for the current user, laying the app-side foundation for the planned Chrome extension. A `killerpdf://open?url=...` link can hand a public HTTPS PDF to KillerPDF whether the app is closed or already running; downloads are size-limited and rejected unless their contents begin as a PDF. The registration refreshes itself when the executable moves.
|
||||||
|
- Open and Save dialogs now return to the last folder successfully used for that kind of operation, unless the caller deliberately supplies another starting folder. The places rail also brings in the user's pinned Windows Explorer Quick Access folders alongside KillerPDF's own editable pins, while avoiding duplicate entries (#178, thanks sheafitzek).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fit Width and Fit Page are now remembered as the preferred fit for subsequently opened PDFs, so users on smaller screens no longer have to switch from Fit Page every time, thanks Thomas.
|
||||||
|
- Owner-restricted encrypted PDFs with malformed linearization tables now pass through KillerPDF's tolerant PDFium cleanup instead of being retried through PdfSharp's fragile read-only parser. This fixes the array-index error that prevented the Fritzbox 4060 manual from opening, thanks Thomas.
|
||||||
|
- Reopening a PDF no longer reapplies a raw zoom saved for a different window or monitor size, which could make the document appear enormous or tiny. KillerPDF keeps the saved page and view mode but fits the document to the current window, with Grid returning to a predictable three-column layout. Perspective correction's corner handles now retain their drag capture across child controls and release reliably, and applying the correction immediately redraws the edited page even when the current zoom does not change.
|
||||||
|
- Multi-line highlights now follow the reading direction of Persian, Arabic, Hebrew, and other right-to-left text. The first selected line extends left from the starting point and the last line extends right to the ending point, while left-to-right documents keep their existing behavior. Direction is detected per line, so mixed-language pages work without a document-wide setting (#170, thanks playerbhr).
|
||||||
|
- Installing KillerPDF for everyone now registers its PDF handler for the whole computer instead of writing it into the elevated administrator's personal registry. Every account can now find KillerPDF in Open With and Default apps, with the shared registration pointing at the Program Files copy; each user still chooses their own PDF default (#176, thanks adeit).
|
||||||
|
- The keyboard-shortcut list now uses the available window width instead of squeezing both halves into a narrow fixed card. Longer translated descriptions have room to remain visible, wrap cleanly on smaller windows, and sit level with the shortcut text in both columns (#177, thanks Mr-Update).
|
||||||
|
- The mouse wheel now moves the file picker's multi-column list horizontally, so folders and files beyond the right edge can be reached without dragging the bottom scrollbar. The folder tree's wheel works too, scrolling vertically normally and horizontally while Shift is held. The shared picker fix applies to Open, Save, image import, signatures, certificates, and every other file-selection flow; icon and details views keep their normal vertical wheel scrolling.
|
||||||
|
- Text annotations, highlights, stamps, ink, and filled form fields already stored in a PDF now appear in KillerPDF and survive printing, flattening, image export, page transforms, thumbnails, and repair rasterization (#141, thanks zenfas). PDFium does not paint annotation appearance streams unless explicitly requested, so every pixel-producing path silently omitted them. Enabling Docnet's annotation flag was not safe because it creates and destroys a form-fill environment while its page remains in use, corrupting PDFium state and crashing on a later native call. KillerPDF now renders through its direct PDFium layer, owns the form callback memory for its full native lifetime, paints both ordinary annotations and interactive widget appearances, then immediately closes the form, page, and one-shot document together. The reporter's five-page D&D Beyond file from #179 now exports and flattens with its filled values and multiline fields intact, without the native teardown crash (#179, thanks hsnopi).
|
||||||
|
- Annotations and stamps now save in the right position on PDFs that already carry a native page rotation when they are first opened (#169, thanks terada-d). The 1.7.0 fix read only KillerPDF's temporary rotation map, but that map is not populated until a page operation performs a temporary save and reload, so annotating an already rotated file and saving it immediately still treated the page as unrotated. Burn-in now falls back to the page's own `/Rotate` value, every newly opened document clears the previous document's temporary rotation map, and saving removes a malformed CropBox that extends outside its MediaBox instead of preserving contradictory portrait and landscape dimensions. Tests cover the reported invalid page boxes and preservation of a valid inset crop.
|
||||||
|
- Filled form fields now generate complete appearance streams, including the required stream length, multiline layout, and WinAnsi text encoding (#180, thanks Ryokoxx). This keeps entered values visible and readable in PDF viewers that strictly validate field appearances, resolving the damaged-file warning, missing line breaks, and replaced punctuation reported in #179 (#179, thanks hsnopi).
|
||||||
|
- Double-clicking bold or italic PDF text to edit it no longer turns the replacement into regular text (#182, thanks fo-bo). PDF text usually carries its face styling inside the embedded font name, such as `Helvetica-BoldOblique`, rather than as separate bold and italic properties. The detector cleaned those suffixes off to find the font family, then explicitly reset both style flags before opening the editor, so the formatting was lost before the first keystroke. Font detection now separates the family from its bold and italic face, applies both to the live edit box, and carries them into the replacement annotation when it is committed. Focused tests cover subset font names and regular, bold, italic, and combined faces.
|
||||||
|
- Clicking a page no longer crashes with "'∞' is not a valid value for property 'Height'" (#181, thanks lachlan-00). The page click rebuilds every annotation and form overlay, and malformed geometry could reach a WPF Width or Height property without being checked. WPF refuses NaN and infinity, so one bad form rectangle or a legacy saved signature with zero canvas dimensions took down the whole viewer during the redraw. Form rectangles and every sized annotation are now checked before they reach WPF; invalid form widgets are skipped, old signature dimensions fall back to the standard canvas size, and the render layer has a final guard for malformed persisted annotations.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Transform's Rotate, Scale, Flip, Skew, and Perspective sections now collapse like the Stamp dialog, keeping the sidebar compact while still allowing every control to remain in one window. Rotate opens initially and the less frequently used sections stay folded until needed.
|
||||||
|
- Refined six German labels and shortcut descriptions for more natural and consistent wording (#150, thanks Mr-Update).
|
||||||
|
|
||||||
|
## [1.7.0] - 2026-08-01
|
||||||
|
|
||||||
|
KillerPDF 1.7.0 introduces split panes, with two documents side by side in one window. It also replaces every stock Windows file dialog, adds a themed system menu and picture-aware night mode, saves non-Latin scripts correctly, and places annotations accurately on rotated pages.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Split pane: F10 shows two documents side by side in one window. Each pane is a card of its own, and the focused one carries an accent ring so it is obvious which pane the toolbar, sidebar and page list are acting on. Click either pane to move focus. The boundary between them has a handle on each side: grab the left one to size the left pane or the right one to size the right. Neither pane can be squeezed below a readable width. F10 closes the split again, and the rail button's icon follows along: a pane pushed out will open, while one pulled back in will close. Each pane has its own tab strip, but the shared toolbar, sidebar, and page list follow whichever pane has focus. Drag a tab from one pane's strip to the other to move it across. On a maximized or snapped window, F10 splits the space evenly instead of squeezing the second pane to its minimum.
|
||||||
|
- Every Open and Save dialog is KillerPDF's own now, instead of the stock Windows one: the same themed window as the rest of the app, with a places rail, a folder tree, list/icon/details views, sortable columns, pinnable folders and recent locations. That covers opening and saving PDFs, merging, extracting pages, flatten, image export, image import, signature and certificate picking, OCR output and the zip export - including picking several files at once where that applies. Shared with the other Killer Tools apps, so the file dialog looks and behaves the same across the family.
|
||||||
|
- Install for everyone on this computer. The Install button on the portable badge now opens a confirmation with two choices: add a desktop shortcut (on by default, as before) and install for all users (off by default because it needs an administrator). An all-users install puts KillerPDF in Program Files with a Start menu entry for every account, and removes the per-user copy so there is a single entry in Add/Remove Programs rather than two. Declining the administrator prompt leaves the app running portable exactly as it was. A `/silent` switch performs the machine-wide install with no interface for winget, Chocolatey, and RMM deployment. This matches Killendar and KillerShell. PDF file associations are still registered only for the current account; an all-users install does not change what PDFs open with for anyone else.
|
||||||
|
- "Confirm before opening links" is back, on the About card beside the recent-files toggle. It asks before a link in a PDF opens in your browser, and it is off by default so links stay immediate unless you want the check. The prompt's own "Don't ask again" now simply switches the same option off, so the two can no longer disagree. The setting had no home after the Settings panel was dissolved; About is where the safety and data-hygiene controls live.
|
||||||
|
- Ctrl+Shift+W closes every tab except the current one, alongside Close Tab on the tab's right-click menu - both now line their keyboard shortcuts up in a real column instead of each item sizing its own.
|
||||||
|
- Right-clicking the title bar (or Alt+Space) shows a themed system menu that matches the app instead of the stock white Windows one. Same items, same behavior, in all ten languages.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Internal: the document view is now a self-contained control rather than part of the main window - the page rendering, zoom, annotations, text editing, crop, forms, links and text selection all moved into it, about 8,700 lines. Every function moved verbatim, verified line by line against the previous version, and no behavior change is intended. This is groundwork for showing two documents side by side in one window.
|
||||||
|
- The document area is a rounded, lifted card like the rest of the Killer Tools apps, instead of a squared pane running flush into the window edges: rounded corners, an 8px inset on its outer side, and a real drop shadow that falls across the status bar. The sidebar's five-dot gripper is gone, replaced by the family divider - a thin line that lights up in the accent color when you hover or drag it. Full screen still fills the display edge to edge.
|
||||||
|
- Night mode no longer inverts pictures (#135, thanks dmantisk). Photos and figures keep their real colors while the page around them goes dark, matching the behavior requested from Okular. Right-click the moon (or press Shift+N) for "Invert images too" if you want the old full inversion back. This is useful on scanned documents, where the whole page is one image. Night mode only changes what you see on screen: saving, printing and exporting still produce the document's original colors.
|
||||||
|
- The Settings panel and its gear are gone: every section moved to where the thing it configures lives, matching the rest of the Killer Tools apps. Theme and language are flyouts on new rail buttons (below the night-mode moon, with a ? button for the shortcuts overlay); the theme flyout stays open across a pick so themes can be compared.
|
||||||
|
- Toolbar appearance is a right-click menu on the toolbar itself, and it is two independent choices now instead of one list of five: icon size (small/large) and text placement (none/beside/under/text only) - so large icons with captions is finally possible. Fresh installs default to large icons with text underneath; existing installs keep their setting. Ctrl+Shift+1-6 pick the options directly.
|
||||||
|
- Internal: the codebase has been reorganized into the Killer Tools family layout - document logic in service classes, the About/CLI/OCR/search features behind controllers, the window partials under Shell/. Every moved function is verbatim and no behavior change is intended; the repo root now holds only the entry files.
|
||||||
|
- New app icon. The old document icon with the red bar across the bottom now marks PDF *files*, so a KillerPDF window and a PDF sitting in a folder are no longer the same picture. Explorer caches icons aggressively, so a PDF may keep showing the old art until the cache refreshes.
|
||||||
|
- View mode is a rail button wearing four view tiles, one per layout: click for a flyout (each mode with its F-key beside it), roll the wheel over it to step through the views, or press F9 to jog from the keyboard - F5-F8 still jump straight to one, and Ctrl+, is retired. All the new shortcuts are on the F1 overlay, in all ten languages.
|
||||||
|
- Internal: the tab strip and split-pane drag/focus model were rewritten to match KillerShell's implementation, the family's reference for both, replacing the original hand-built version.
|
||||||
|
- The sidebar's left/right choice sits at the bottom of the sidebar's right-click menu, which now opens from any part of the sidebar; Ctrl+Shift+B flips the side, pairing with Ctrl+B's collapse.
|
||||||
|
- The content pane's border is a shade lighter in every theme, so the edge between the pane and the chrome reads more clearly - the same value the other Killer Tools apps use.
|
||||||
|
- The prompt offering to make KillerPDF your default PDF viewer is translated now; it was English-only in every interface language.
|
||||||
|
- The sidebar's page list fades into the background at its top and bottom edges while there are pages scrolled past them - the same treatment KillerShell's folder tree and the killerpdf.net sidebar use. Each fade ramps in over its own height as a row slides under it, so nothing pops, and neither shows when the list is flush at that end.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Japanese and other non-Latin text no longer saves as empty boxes (#168, thanks terada-d). The editor is a Windows text box, which quietly borrows glyphs from any installed font, so what you type always looks right; the save path resolved a single font and wrote a box for every character that font lacked. Two things were wrong. Nearly every CJK font on Windows ships as a collection file (Yu Gothic, MS Gothic, Meiryo, YaHei, JhengHei), and the save path could not read collections at all. Even picking a Japanese font by hand did not help. Nothing checked whether the chosen font could carry the text either. KillerPDF now reads collection fonts, and when your font cannot render something it picks one that can, preferring the same faces Windows itself falls back to. Your own font is always used when it covers the text. This affects Bengali, Korean, Chinese, Thai, Arabic and Indic scripts too, and applies to page numbers and watermarks as well as placed text. Embedded fonts are subset, so a line of Japanese adds tens of KB to the file rather than the whole typeface. If nothing installed can draw a character, KillerPDF now says so when the text is placed and lists the unsupported characters. This rare case can happen when one box contains two non-Latin scripts or the PC has no font for the requested script.
|
||||||
|
- Punctuation shortcuts work on keyboard layouts that need Shift for those characters (#153, thanks Mr-Update). Shortcuts were matched by key position, which is a US-layout assumption: on a German keyboard "?" lives on Shift+ss and "=" on Shift+0, so Ctrl+? and Ctrl+= pressed keys the app was not listening for, and the extra Shift broke the match a second time. Zoom and the shortcuts overlay now respond to the key that TYPES the character, whatever position it occupies, so they work on German, French, Nordic and other layouts rather than being fixed one at a time. The shortcuts overlay also prints the spelling that is right for the keyboard in use instead of always showing the US one.
|
||||||
|
- Annotations and stamps are no longer burned into the wrong frame on a rotated page (#169, thanks terada-d for a report that diagnosed it down to the line). A page's rotation is deliberately kept outside the working document, so the canvas you draw on is in the rotated frame while the save path writes into the page's own unrotated one - and the save path was never told the angle. Anything placed on a quarter-turned page came out rotated 90 degrees from where you put it, offset, and scaled on swapped axes, which also squeezed text boxes narrow enough to wrap after almost every character. Stamps had the same fault, so page numbers and watermarks landed in the wrong corner and disagreed with their own preview. The rotation now travels with the burn, in the editor and in the background flatten that print uses.
|
||||||
|
- The app-size readout parked itself on the status bar. Rolling the wheel over the logo wrote "App size N%" into the footer behind a short hold, which existed so the chrome resize could not stomp the message with its own page and zoom status the same frame. When that hold expired nothing repainted the line, so the readout stayed put until the next page change, tool switch or open happened to write over it. It is transient now. Each notch rewrites the readout and restarts a five second timer, and the status line goes back to what it was showing before the first notch when the timer expires. The hold is unchanged and still only covers the same-frame stomp. If something else wrote a status after the hold lapsed the restore is skipped, so a real message is never replaced by a stale one.
|
||||||
|
- Editing a line of text could collapse it to 3pt (#163, fixed in #165, thanks Ryokoxx). The font size was read from the size written in the content stream, which is only the visual size when the text matrix does not scale - a generator that emits `/F1 1 Tf` and applies the scale through the matrix reported 1, and the replacement text hit its lower clamp. The point size is used now, falling back to the old value and then to the line-height estimate, since the point size can be zero on fonts with no usable metrics. Covered by tests that pin both spellings.
|
||||||
|
- Rotating a quarter-turned page by a few degrees no longer squashes it back to portrait (#167, thanks japsmits). The transform rendered the page with its rotation but sized the result from the unrotated page box. On a landscape page, that disagreement stretched the result vertically to fit the old portrait shape. The page dimensions now follow the rendered orientation in both page-size modes.
|
||||||
|
- Ctrl+0 and Ctrl+1 did not reset the zoom to a true 100% (#154, thanks Ryokoxx). The internal zoom level scales each page's layout box, and outside Continuous that box is the render-dimension bitmap rather than the page's natural width - so asking for 1.0 landed near 200% in Single, Two-Page and Grid. Absolute zoom requests now convert through the same display factor the zoom dropdown presets already used, so 100% means 100% in every view mode.
|
||||||
|
- A signature dropped onto a fill-in form field is no longer hidden behind it (#156, thanks Peter5164). Redrawing a page paints the annotations first and then restores the interactive field overlays on top of them, so anything placed over a field vanished underneath it. The field overlays now sit below the annotation layer; they stay clickable, since annotation visuals never intercept the mouse.
|
||||||
|
- The page-number tooltip now shows on every page in every view mode (#151, thanks Mr-Update). It was only ever set on the secondary page tiles, so Single and Continuous had none, Two-Page only showed it on the right-hand page, and Grid started at page 2.
|
||||||
|
- Text edit could not pick up a line's font when the letter data left the name blank (#166, thanks Ryokoxx). The fallback read the font name off the word, which joins its letters' names into one string ("Helvetica Helvetica Helvetica ..."), so nothing could resolve it and the edit box landed on the default font - the same result as having no fallback. It reads the letter's name only now.
|
||||||
|
- The odd/even page filter never reached the print job (#159, thanks Ryokoxx). The preview and the sheet count both read the filtered page list, but the print path re-parsed the typed range on its own and so printed every page in it. All three now walk the same list, and "print odds, flip the stack, print evens" works as intended.
|
||||||
|
- A link annotation with no /Subtype entry no longer aborts the pre-save link-border strip with a NullReferenceException mid-save - it is skipped like any other unreadable annotation. Surfaced while the scrubs moved to their service class; the old code dereferenced the missing entry before checking it.
|
||||||
|
- Picking a color with the screen eyedropper and pressing OK could silently throw the pick away - the tool then kept drawing whatever color last got through, which read as "shapes ignore the color I chose". The eyedropper opens a second modal window inside the color dialog, and closing that inner window could corrupt the outer dialog's OK/Cancel result, so a real OK came back as a cancel. The dialog now reports its committed color through its own flag instead of trusting that result. The eyedropper button also gained a proper hover and a lit armed state while the crosshair is active, and no longer shows the crosshair cursor just for hovering it.
|
||||||
|
|
||||||
|
## [1.6.6] - 2026-07-23
|
||||||
|
|
||||||
|
KillerPDF 1.6.6 is primarily a bug fix release. Most importantly, it corrects form fields that appeared in the wrong place on non-A4 documents. It also remaps tool hotkeys, adds Remove Password, and includes several menu, keyboard, and interface improvements.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Remove Password in the Save dropdown (#149, thanks dmantisk): saves the open document back over the original with its password protection dropped - available whenever the file needed a password (or carried owner restrictions) to open. KillerPDF already strips encryption at open time because the editing pipeline cannot modify encrypted files in place, so every save has always written an unprotected PDF; this makes that behavior a visible, deliberate action, and regular saves of a previously protected file now say so in the status bar instead of dropping the password silently. In all ten languages.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Tool hotkey remap - the digits again mirror the toolbar left to right, with Shapes slotted in (breaks some muscle memory; the letter keys are unchanged): V = Select (the Photoshop / Illustrator / Figma convention; its old digit went to Text), 1 = Text, 2 = Highlight, 3 = Line, 4 = Shapes, 5 = Draw, 6 = Image, 7 = Signature, 8 = Crop, 9 = Transform, 0 = Stamp. The toolbar buttons reorder to match (Highlight now before Line, Shapes between Line and Draw), and the Shapes tool has a keyboard shortcut for the first time. The shortcuts overlay (both views), tooltips, and the help page follow.
|
||||||
|
- Invert document colors moved from Ctrl+I to the bare N key (night mode), freeing the conventional italic chord: Ctrl+B / Ctrl+I / Ctrl+U now toggle Bold / Italic / Underline while typing in a text box, matching the text bar's B/I/U buttons. Listed in the shortcuts overlay in all ten languages.
|
||||||
|
- Esc now steps down instead of straight out. With nothing left to cancel, it first returns to the Select tool, Acrobat-style, and only a second Esc exits the app as before. The Highlight tools' hint on a page with no text layer now points at the deliberate rectangle path: "No text here. Shapes is on 4." The message is translated into all ten languages.
|
||||||
|
- The right-click menus caught up with 1.6.5's menu polish: every item in the page, annotation, sidebar-thumbnail, and background context menus now carries its icon in the left gutter, matching the toolbar's glyph for the same action - and page rotation gets a proper mirrored CW / CCW pair.
|
||||||
|
- The page sidebar now starts collapsed when no PDF is open because an empty workspace has no thumbnails to show. It opens when a document loads and collapses again when the last document closes. The empty page-number box and "/ -" that used to sit in the sidebar header are also hidden until a document is open.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Interactive form-field overlays sat in the wrong place on any document that is not A4-sized - shifted down and slightly wide, worst near the top of the page, while the page itself (and every other viewer) drew the fields correctly. PdfSharpCore's page.Width getter, which the link layer touches on every render, silently converts the parsed /MediaBox array into its internal rectangle type; the field parser's array read then came up empty and fell back to a hardcoded A4 page size, so only A4 documents lined up. The field parser now reads both representations and walks the page-tree inheritance chain for /MediaBox and /CropBox. Found through the brochure: the shipped copy is A4, so the bug was invisible until a US Letter rebuild put every field about 40 points adrift.
|
||||||
|
- The Document Info shortcut label showed mojibake in Spanish, Bengali, and both Chinese interfaces - the same double-encoding repaired for Japanese in 1.6.5 (#136). All four now render their real text.
|
||||||
|
- Exported JPEGs no longer come out as black pages, and exported PNGs no longer carry a transparent background (#148, thanks Ryokoxx). PDFium leaves unpainted background pixels fully transparent. The JPEG encoder dropped that alpha channel and kept the zeroed color underneath, so most PDFs rendered solid black through `--to-image --format jpg` and the new Export pages as images dialog. Exports now composite over white by default, which also keeps the needless full-page alpha channel out of flattened PDFs (`--flatten` and Save Flattened). A new `--transparent` flag on `--to-image` keeps the raw alpha for PNG output when transparency is actually wanted.
|
||||||
|
- The Password Required prompt now matches the rest of the app, with a wordmark title bar, a dark film-grain card, a themed password field, and Open and Cancel buttons. It replaces the stock white Windows dialog and native chrome.
|
||||||
|
|
||||||
|
## [1.6.5] - 2026-07-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Shapes tool: rectangle, ellipse, and free-form polygon markers, each with an optional fill. Box keeps the classic drag-a-filled-rectangle gesture the highlighter used to have; ellipse and polygon are closed outlines that move, resize, flatten, and print like any other drawing. Freeform places points click by click - click the first point (its target lights up when you are close) or double-click to close, Esc cancels, Backspace removes the last point. The tool shares the draw bar's color, size, and opacity, with a mini-shape sub-mode picker and a Fill toggle.
|
||||||
|
- Export pages as images (#132, thanks KaneLeung): a new entry in the Save dropdown renders pages to PNG or JPEG files at a chosen DPI (24-1200, default 150) with an optional page range, through the same pipeline as the CLI's `--to-image`. Pending annotations and stamps are burned in, in-app rotations are honored, and files land as `<name>-page-001.png` next to the base name you pick.
|
||||||
|
- Odd/even page printing (#134, thanks superaustingao): a new selector under Pages offers All pages, Odd pages only, and Even pages only. It filters the chosen page range, and the preview follows along. Print the odds, flip the stack, then print the evens for manual duplex on printers without a duplexer.
|
||||||
|
- Invert document colors (#135, thanks dmantisk): a moon toggle at the bottom of the sidebar rail (or Ctrl+I) renders the document with inverted colors for dark-mode reading - the icon lights in the accent while active, and the choice is remembered across launches. Display only: saving, printing, exporting, OCR, and the sidebar thumbnails all keep the document's true colors.
|
||||||
|
- App-wide size control for accessibility, the KillerNotes way: the title bar now shows the app icon next to the wordmark. Scrolling the mouse wheel over that logo scales the toolbar, sidebar, and tab strip in fine steps from 70% to 250%. Ctrl+Shift with the plus or minus key adjusts it from the keyboard, and Ctrl+Shift+0 resets it. The setting is remembered across launches. The document pane is deliberately untouched: app size and page zoom stay separate controls, so scaling the chrome never changes what the page looks like. It uses a layout scale so UI text stays sharp, and the title bar and footer stay fixed so the logo never moves out from under the cursor.
|
||||||
|
- Recent-files privacy controls (#146, thanks Bolle1987): a Clear list link on the start screen's Recent panel (matching the one already in the Open dropdown), and a "Don't remember recently opened files" toggle in the About window next to Clear all Data, where the data-hygiene controls live - turning it on also empties the existing list, so nothing about your documents persists on a shared machine. Translated into all ten languages.
|
||||||
|
- Czech (cs-CZ) localization (#138, thanks jiri-ops): the tenth interface language, a full translation following Czech Windows/Adobe conventions, in the language picker as "Čeština" - with Czech ("ces") joining the OCR language catalog, downloadable on demand like the rest.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Page numbers and watermarks are now written into the saved PDF when they are the document's only markup (#147, thanks Mr-Update). Every save path burned the stamp layer only when the document also carried an annotation. As a result, stamping a clean document produced a PDF with nothing on it.
|
||||||
|
- Stamps can be removed again (#145, thanks Mr-Update). Unchecking both Page Numbers and Watermark disabled the Apply button, so once a document had stamps there was no way to turn them off - applying with both sections off is exactly how they are cleared, and is now allowed whenever the document already has stamps.
|
||||||
|
- Fixed a crash when opening Stamp or Transform, or saving a page with a multi-line text annotation (#142, thanks TrNguyen20; root cause and fix from Ryokoxx in #144). The burn silently used justified alignment, whose draw path dereferenced the empty line-break blocks produced by a newline. Burned text is now explicitly left-aligned, finally matching the editor once a line wraps. The vendored formatter also skips line-break blocks and no longer flings blocks off the page on single-word justified lines. The typeface behind a text box resolves lazily, so a font can still fail at first draw on a machine missing that face. In that case, the draw falls back to the stock font and then skips only the failing annotation. A failed preview burn renders the page without its annotation layer instead of taking down the app.
|
||||||
|
- The pre-save signature scrub tripped a NullReferenceException on every save of a document with no form fields (a fresh blank document, most PDFs without forms) - swallowed silently in release builds, but it aborted the scrub early and broke into any attached debugger. Absent dictionary entries like a missing /AcroForm are now treated as "not there" instead of dereferenced.
|
||||||
|
- Bookmarks that point at named destinations now resolve (#143, thanks Ryokoxx). PDFs from HTML-to-PDF generators (wkhtmltopdf underneath most invoice and statement tools) write outline destinations as names looked up through the catalog, which the outline loader did not handle - Debug builds popped an assertion dialog and Release builds left the bookmark silently dead. Resolution now falls back to the same name-tree walker the link layer already uses.
|
||||||
|
- The sidebar page thumbnails, outline tooltips, and grid-view tooltips always said English "Page N" regardless of the interface language (#137, thanks jiri-ops) - the labels are now real localized strings in every language, and they update immediately on a language switch.
|
||||||
|
- Japanese: repaired a garbled Document Info shortcut label (mojibake) and tightened the About wording (#136, thanks coolvitto).
|
||||||
|
- Fresh clones build again without manual repair: an explicit .gitattributes rule keeps EOL normalization away from the vendored third_party sources (#140, thanks Ryokoxx), belt and braces on top of the earlier re-encode.
|
||||||
|
- The Shapes tool strings and the outline's "(untitled)" placeholder existed only in English and Czech - the other eight languages showed blank tooltips and labels there. All ten languages now carry the full string set, verified key-for-key against English.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Text selection now flows with the text (#127, thanks Ryokoxx): dragging with the Select tool tracks the actual run of characters in reading order, browser-style - across lines, paragraphs, and (in continuous view) across pages. A plain click still selects annotations, and drags that start on empty page keep the classic box select, so scans and annotation multi-select behave as before. Ctrl+A now shows real per-line selection on the page.
|
||||||
|
- Highlight, Strikethrough, and Underline follow the text the same way: drag over words and the markup hugs each line instead of laying down one rectangle. One gesture produces one grouped annotation per page - it selects, moves, deletes, and undoes as a single unit. On pages with no text layer the tools show a status hint instead of silently drawing a box; the highlight eraser keeps its rectangle.
|
||||||
|
- Black theme: the on-page selection color was a stray royal blue; it is now a readable dark green matching the theme.
|
||||||
|
- The form-field font-size stepper is now an "inline flyout" - a new style for controls that float on the document itself: a translucent rounded pill that drips down from the field being typed in, follows it through scrolling and zoom, flips above it at the bottom of the pane, and solidifies on hover. Subtle enough to sit on a legal document without being in the way, and it can no longer collide with the draw/text bars or the toolbar.
|
||||||
|
- Menu polish: dropdown items can carry icons in the gutter the check column always reserved (Save, Open, and OCR menus got them), and the OCR "Use High Quality Models" toggle now keeps the menu open, refreshing its checkmark and the per-language "(download)" labels in place.
|
||||||
|
- Tooltips now show their keyboard shortcut everywhere one exists, in all ten languages: the whole tool palette carries its single-key hint (V select, T text, H highlight, D draw, L line, I image, G signature, C crop, R transform, S stamp), and the invert and app-size controls show Ctrl+I and Ctrl+Shift+=/-/0. The shortcuts overlay's list view also caught up with the keyboard view: Ctrl+Shift+Z (redo), Ctrl+Shift+Tab (previous tab), and F2 (rename bookmark) are listed now.
|
||||||
|
- Collapsing and expanding the sidebar is now a smooth slide instead of a snap: the panel glides shut over a quarter second with the thumbnails holding their size (clipped, not squished), and the document settles in a single crisp pass afterwards - the same pipeline a splitter drag uses.
|
||||||
|
|
||||||
|
## [1.6.4] - 2026-07-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Full command-line interface: `--merge`, `--extract-pages`, `--split`, `--decrypt`, `--to-image`, `--flatten`, `--print`, `--ocr`, `--version`, and `--help` run headlessly with meaningful exit codes, work while the app window is open, and reuse the exact pipelines the GUI runs (merge link rewriting, pre-save scrubs, lossless PDFium decrypt, rotation-safe 150/300 dpi rasterizing, searchable-PDF OCR with on-demand language download). See the Command Line section on the help page.
|
||||||
|
- Bookmark editing in the sidebar Outline panel (#133, thanks alivio-israu): add via the row at the top of the tree (named in place), inline rename, child bookmarks, reorder, retarget, and delete - with Ctrl/Shift multi-select, Delete and F2 keys, delete all, and full Ctrl+Z undo. Hidden on read-only files.
|
||||||
|
- Redo: Ctrl+Y (or Ctrl+Shift+Z) re-applies undone actions - annotations, text edits, stamps, clears, and document-level operations alike. Any new edit clears the redo chain, and redo history is kept per tab.
|
||||||
|
- Jump history: Alt+Left / Alt+Right and the mouse back / forward buttons retrace bookmark, link, jump-box, and Home/End jumps, browser-style.
|
||||||
|
- Keyboard view in the shortcuts overlay (F1): a visual keyboard with every bound key lit and color-coded by category. Toggle LIST / KEYBOARD in the header (the choice sticks), click a layer or hold Ctrl / Shift / Alt to preview it, and hover a lit key for its action. Follows the active theme and language.
|
||||||
|
- More conventions from the big viewers: Home / End jump to the first / last page, Ctrl+1 / Ctrl+2 / Ctrl+3 set actual size / fit width / fit page, and the Menu key or Shift+F10 opens the right-click menu at the current selection (keyboard accessibility).
|
||||||
|
- Japanese OCR language (`jpn`), downloadable on demand like the rest - the OCR language list now covers the same nine languages as the interface.
|
||||||
|
- Command-line batch mode: `KillerPDF.exe --batch-resave <input> <output> [--log report.csv] [--quiet]` resaves a single PDF or a whole folder tree headlessly through the standard open/save pipeline, with per-file OK/SKIP/FAIL reporting. Built for the validation harness.
|
||||||
|
- Standards-conformance validation harness (`validation/`): `Compare-VeraPDF.ps1` diffs two veraPDF batch reports (corpus baseline vs a `--batch-resave` output tree) and flags any file whose validation outcome a KillerPDF save changed. Verifies that saving through KillerPDF does not degrade PDF/A conformance.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Shortcut remap: About moved from F2 to F12, and Document Info moved from F12 to F4 (Ctrl+D also works, matching Acrobat/Foxit/Sumatra's Document Properties). F2 now renames the selected bookmark in the Outline panel, the Windows rename convention. Settings gained F9 (Ctrl+, also works, the VS Code / Windows Terminal convention), and F3 / Shift+F3 step to the next / previous search match from anywhere (F3 opens search when it isn't). Pressing a dialog shortcut while the shortcuts overlay is open dismisses the overlay first. The shortcuts overlay and the help page keyboard map follow.
|
||||||
|
- Keyboard shortcut hints audited app-wide: menus now show their shortcut dimmed at the right edge wherever one exists (OCR, close tab, bookmark rename/delete, and more), the help tooltip advertises F1, and missing tooltip hints were added in all nine languages (OCR Ctrl+Shift+O, sidebar collapse Ctrl+B, grid view F8).
|
||||||
|
- Continuous view: clicking a page no longer snap-scrolls its top to the viewport. Clicks in the document are for tools and selection only, and the current page follows the viewport as you scroll - the convention the big viewers use. The sidebar, jump box, links, bookmarks, and page keys still jump as before (#128, thanks Ryokoxx).
|
||||||
|
- German translation refinements: Dokumentinfo, Zuschneidebereich for CropBox, Entf for the Delete key (thanks Mr-Update, #126).
|
||||||
|
- The sidebar tab is labeled OUTLINE (singular) in English, matching the other languages.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Resaving a PDF no longer reduces its PDF/A conformance. The PDF library (PdfSharpCore, MIT) is now vendored under third_party/ with six patches: no Producer/Creator stamping into an imported document's Info dictionary, no /ModDate rewrite at open, no transparency /Group injected into every page, stream /Length now always matches the spec's byte count (empty streams included), boolean values written as the spec's lowercase true/false keywords, and the debug-only verbose file layout removed. Found by the new veraPDF validation harness across a 2,900-file corpus.
|
||||||
|
- Intermittent hard crash (native heap corruption) while scrolling or clicking through a document, most visible on annotation-heavy pages: KillerPDF's direct PDFium calls (link extraction, encryption stripping) could land at the same moment as a background page render inside PDFium, which is single-threaded. Every direct call now holds the same lock the render path uses. Diagnosed from a 1.6.3 crash dump showing two threads inside PDFium at once.
|
||||||
|
- Saving a PDF that carries a digital signature kept the old signature value even though any edit breaks its digest (which must cover the entire file), so strict validators rejected the result. Saves now strip dead signature values and the matching /Perms entry; the signature fields themselves are kept.
|
||||||
|
- Saving over the open file failed with "being used by another process" on PDFs whose pages carry annotations but no links readable by the primary parser (typically fillable forms): the cached PDFium link handle was holding the file open. It is now released before every save (#129, thanks Peter5164).
|
||||||
|
- Opening a PDF whose page tree parses to zero pages crashed Continuous view with an out-of-range page index; it is now guarded (#130, thanks demo1866).
|
||||||
|
- Bookmark titles in password-protected PDFs showed as mojibake (a stray BOM prefix followed by garbled characters) instead of their Unicode text - most visible on Chinese outlines. Titles the parser hands over raw are now re-decoded for display (#133, thanks alivio-israu).
|
||||||
|
- Grid view never tracked the current page while scrolling, so the statusbar counter, the page jump box, and the page a new bookmark targets could all point at a page long since scrolled away. Grid now follows the tile nearest the viewport center, like Continuous.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Image codec library SixLabors.ImageSharp updated from 1.0.4 to 2.1.13, clearing all seven published advisories against the old version (denial-of-service and out-of-bounds issues in image parsing). Image import, clipboard paste, and signature images all pass untrusted files through this library.
|
||||||
|
|
||||||
|
## [1.6.3] - 2026-07-12
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Links open directly again: the confirm-before-opening prompt and its Settings row are off for now.
|
||||||
|
- When both document scrollbars are visible, the vertical bar now runs the full pane height and owns the corner.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Switching from Grid to Continuous view kept the grid's scrollbar overrides, clipping zoomed pages with no horizontal scrollbar. Continuous now restores its own scrollbar setup.
|
||||||
|
- Closing with unsaved changes stacked two prompts. Confirming "close without saving" now counts as the quit confirmation, and the prompt defaults to No so a stray Enter can't discard new work.
|
||||||
|
- Saving any PDF whose pages had no crop box silently planted a zero-size /CropBox on every page, which Adobe rejects with a "page dimensions out-of-range" error - the real reason merged Google Docs exports failed in Acrobat but opened in Chrome. Page boxes are now read without touching the document, and every save strips degenerate crop boxes, so re-saving a file damaged by 1.6.x heals it (thanks Richard Lam).
|
||||||
|
- The quit prompt no longer appears when no documents are open - an empty window just closes.
|
||||||
|
- Saving any PDF that has no bookmarks silently corrupted the file's structure (a dangling /Outlines reference). Strict viewers refused the file with a repair prompt, and the repair stripped fillable forms. Saves are now clean, and the repair path first tries a lossless PDFium re-save that preserves forms and bookmarks, so files damaged by older builds recover intact (#103, thanks Peter5164).
|
||||||
|
- Two-Page mode: arrow keys, PgUp/PgDn, and the wheel's edge page-flip now move one spread at a time instead of one page (#120, thanks eddardburger).
|
||||||
|
- Selection boxes drawn with the Select tool could get stranded on screen until the app was restarted. They are now removed from the layer they actually live on, and closing a file sweeps any stragglers (#121, thanks TaBnLd).
|
||||||
|
- High memory use on large documents (#122, thanks RoyYang567): the per-tab page-bitmap cache is now capped to a window of pages around the viewport, closing a tab compacts the heap so RAM visibly drops, and Continuous view only holds bitmaps for pages near the viewport - a 243-page image-heavy PDF now costs a few hundred MB instead of climbing past 7 GB.
|
||||||
|
|
||||||
|
## [1.6.2] - 2026-07-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Page Up / Page Down navigate to the previous / next page regardless of what has focus. Page reordering stays on the toolbar Move Up / Move Down buttons (#117).
|
||||||
|
- Japanese (ja-JP) interface translation, selectable from the language picker (#118, thanks coolvitto).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Footer/status bar tightened to match the killerpdf.net statusbar: 4px shorter with larger text, and the corner grip dots now stay visible when the window is maximized or snapped.
|
||||||
|
- Ctrl+scroll zooming is smooth: each wheel notch zooms by a constant 10% ratio, the view scales instantly while the wheel is moving, and the crisp high-resolution re-render happens once when the wheel rests. Precision touchpads glide proportionally.
|
||||||
|
- Up / Down arrows now scroll the view like the mouse wheel, flipping pages at the top or bottom edge. Left / Right and PgUp / PgDn remain hard page jumps.
|
||||||
|
- Status-bar and dialog messages that were still shown in English now follow the selected language across all nine locales.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Switching view modes now cross-fades instead of cutting instantly, with no intermediate-frame flashes.
|
||||||
|
- The in-app self-updater now reads `SHA256SUMS.txt` from the release assets instead of the repo at the release tag, so the hash can no longer drift from the binary and fail the update's checksum.
|
||||||
|
- Importing images with broken DPI metadata (common in WhatsApp photos and some scans) produced pages Adobe Reader refuses to display; imported image pages are now kept within Adobe's supported 3-14,400 point range (thanks Richard Lam).
|
||||||
|
- Saving a document that already contains out-of-range pages now offers to scale them to a supported size; the pages keep their look and proportions.
|
||||||
|
|
||||||
|
## [1.6.1] - 2026-07-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- On quit with documents open, KillerPDF asks whether to reopen them next launch, with a "remember my choice" option (#105).
|
||||||
|
- Enter and Esc now confirm and cancel dialogs (#111).
|
||||||
|
- Right-clicking the Open, Save, and OCR toolbar buttons opens their dropdown menu (#109, thanks Ryokoxx).
|
||||||
|
- Copies and custom Scale in the print dialog are numeric fields with an up/down spinner, arrow-key and wheel stepping (#109, thanks Ryokoxx).
|
||||||
|
- The print dialog remembers the last printer, orientation, color, and two-sided choice (#109, thanks Ryokoxx).
|
||||||
|
- Improved German translation (#114, thanks Mr-Update).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Mouse wheel scrolling is faster in all view modes and the page sidebar.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Continuous view stays sharp when zooming in and on high-DPI displays; visible pages re-render at a higher resolution (#85).
|
||||||
|
- Open menu: the remove (X) button on each recent-files entry was clipped off the right edge of the dropdown; it now stays inside the frame.
|
||||||
|
- Crash when saving a freshly merged or imported PDF (#112).
|
||||||
|
- Save failing with "Cannot retrieve stream length"; the file is now recovered automatically (#106).
|
||||||
|
- Startup crash on older Windows 10 / .NET Framework builds (#101).
|
||||||
|
- Toolbar dropdown carets (Recent files, Save, OCR) missing on Windows 10 (#104, #108, thanks again Ryokoxx).
|
||||||
|
- Extra copy when printing multiple copies on some printers (#83, #107).
|
||||||
|
|
||||||
|
## [1.6.0] - 2026-06-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Tabbed documents: open several PDFs at once, each restoring its page, zoom, and view mode. Drag tabs to re-order.
|
||||||
|
- OCR built into the single exe (Tesseract): OCR a whole page or a dragged region to the clipboard, Make Searchable PDF (an invisible text layer over the scan), and Extract All Text to a .txt or .md file. A language picker downloads extra languages on demand, with an optional high-quality model toggle.
|
||||||
|
- Digital signatures with a cloud certificate (Certum SimplySign): reusable signatures and initials, click-to-sign form fields, and a movable Signatures popup that remembers its position.
|
||||||
|
- Transform tool: rotate in 90-degree steps or by a fine angle, scale, flip, and straighten a crooked scan by drawing a line along anything that should be level, all with a live preview. Annotations on the page follow the transform.
|
||||||
|
- Annotation tools: Line tool plus refreshed draw and highlighter bars, each with its own color, opacity, and width; resizable, word-wrapping text boxes (double-click to re-edit) with an optional whiteout background fill.
|
||||||
|
- Select tool moves and resizes any annotation, Shift+click to multi-select, marquee-selects across page boundaries, and reopens an annotation's bar to restyle it in place.
|
||||||
|
- Full RGB color picker on every swatch row: saturation/value square, hue strip, RGB/hex inputs, a screen eyedropper, and an editable palette.
|
||||||
|
- Print options: scale, position, margins, pages per sheet, color / black-and-white, and two-sided.
|
||||||
|
- Page-number stamping from the right-click menu (start value, format, position, size) as one undo.
|
||||||
|
- Drop a folder or .zip archive onto the window to open the PDFs and images inside, choosing to merge them into one PDF or open each in its own tab.
|
||||||
|
- Document Info dialog (F12): view and edit a PDF's title, author, subject, keywords, and creator metadata.
|
||||||
|
- Recent files: a dropdown by Open (last 10) and on the start screen, plus a Save / Save As dropdown; each entry carries its real Windows file-type icon.
|
||||||
|
- Keyboard shortcuts for tools, views, and panels (F1 shortcuts list, F2 About, Ctrl+V paste, Esc to close, F5-F8 view modes, F11 fullscreen...); the overlay lists them all and links to the full online guide.
|
||||||
|
- Full-screen mode (F11): hides all chrome so only the document fills the monitor, with a black fade in and out.
|
||||||
|
- Per-field font size while filling text fields, baked into the saved PDF.
|
||||||
|
- One-click update from the About dialog when a newer release exists.
|
||||||
|
- Toolbar style picker: small or large icons, text beside, under, or only.
|
||||||
|
- Sidebar is resizable and can be placed either left or right, with the collapse toggle, splitter, and Settings flyout mirroring to match.
|
||||||
|
- Accent colors (red, orange, green, teal, blue, purple) for the Dark, Light, and Black themes, each remembered independently.
|
||||||
|
- "Clear all Data" link in the About window to wipe settings, downloaded OCR language models, and temp files.
|
||||||
|
- Bengali, Turkish, Simplified Chinese, German, and French translations (contributors akib-h #79, mrantikadev #76, KaneLeung #82, Dtrieb & Gevlug #93, Thalis-fr #95).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Visual refresh: new logo, wordmark, app and PDF-file icons, fonts, and colors throughout.
|
||||||
|
- Blood, Greed, and Cyanotic use darker chrome with a lighter document pane; the signature windows are fully themed and reload on theme change.
|
||||||
|
- Settings is now a slide-out accordion (Language, Theme, Toolbar, View Mode, Sidebar) that stays open after a pick.
|
||||||
|
- Crop tool rebuilt as a single docked, slidable bar matching the annotation bars.
|
||||||
|
- Text-over-text editing drops an opaque cover (fill sampled from the page) with an editable box on top; the pair can be unpaired, and image-only pages get a manual cover and box.
|
||||||
|
- Unified the page-rendering pipeline so annotations, search highlights, and tools behave identically across Single, Continuous, Two-Page, and Grid views.
|
||||||
|
- Grid and Two-Page pages render sharper on high-DPI displays.
|
||||||
|
- Restored sessions load tabs lazily, and placed images no longer re-decode while being dragged.
|
||||||
|
- Save Flattened opens the source PDF once instead of per page (Issue #68).
|
||||||
|
- Internal refactor: the ~15,000-line MainWindow code-behind split into ~40 focused partial-class files, no behavior change.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Prints now rasterize at a true 300 DPI instead of the preview's ~140, so output is sharp; the preview itself renders lighter and only the pages being printed are re-rendered at full resolution, keeping memory in check on large files (Issue #83).
|
||||||
|
- Printing and Save Flattened no longer crash on documents PdfSharpCore can't reopen; they use the same repair fallback as Save.
|
||||||
|
- Opening an encrypted PDF or repairing a damaged one runs on a background thread instead of freezing the window.
|
||||||
|
- A manually-closed PDF no longer reopens on next launch (Issue #75).
|
||||||
|
- Form fields appear and fill in every view mode, align on pages with an inset CropBox or offset origin, and size their text from the field's own /DA.
|
||||||
|
- Grid view: the wheel keeps scrolling after a zoom or column change, page jumps fit correctly (Issue #78), and annotations commit to the page they were drawn on.
|
||||||
|
- Undo removes one item per press; a held Ctrl+Z no longer fires several at once.
|
||||||
|
- Clear All Annotations clears every view mode as one undo; right-click Clear Page Annotations targets the correct page.
|
||||||
|
- Search waits for a pause in typing before running; the Outlines panel scrolls and no longer auto-expands every branch.
|
||||||
|
- Pressing Esc during a long OCR, repair, or flatten operation asks whether to cancel instead of closing the window.
|
||||||
|
|
||||||
|
## [1.5.1] - 2026-06-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- PDFs that opened fine in browsers and Acrobat/Foxit but failed in KillerPDF with "Unexpected EOF" now open. PdfSharpCore rejected them during parsing; KillerPDF now falls back to re-saving the file losslessly through PDFium (which reads them) and opening that copy (Issue #72).
|
||||||
|
- Files opened from UNC / network shares (including the WSL `\\wsl$` filesystem) are now copied to a local temp before opening, avoiding partial-read failures on network filesystems.
|
||||||
|
- Grid view now renders every page, and tiles stream in progressively as they render instead of blocking until the whole document is done. Grid was previously capped at the first 26 pages, so longer documents stopped loading partway through.
|
||||||
|
- Ctrl+Scroll in grid view no longer re-renders every page when the zoom is already at its limit (the column count cannot change), which made large documents reload pointlessly.
|
||||||
|
- Lowered the minimum zoom from 10% to 5% so grid view can pack more columns (useful for wide/landscape pages) and single-page view can zoom out further.
|
||||||
|
- Removed a stray horizontal scrollbar (a thin green line) that appeared across the bottom of grid view; grid fits its columns to the window and no longer scrolls sideways.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Save Flattened PDF now rasterizes across multiple CPU cores. PNG encoding runs in parallel; the PDFium render step is serialized because the library is not thread-safe. Large documents flatten faster and the UI stays responsive (Issue #68).
|
||||||
|
|
||||||
|
## [1.5.0] - 2026-06-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Localization support (Issue #53 / contributor leox243). Language selector in Settings panel. Ships with English (en-US), Spanish (es), and Traditional Chinese (zh-TW). Theme names, zoom dropdown, fit-mode status, and keyboard shortcut overlay all update with the selected language. Contributor guide at `Strings/TRANSLATING.md`.
|
||||||
|
- Continuous scroll view mode. Opens all pages in a single vertical strip with progressive async rendering. Page number and sidebar thumbnail track automatically as you scroll.
|
||||||
|
- Two-page view mode. Displays two pages side-by-side (primary + one secondary). Editing tools are available in this mode.
|
||||||
|
- Re-edit placed text by double-clicking it with the Select tool. The text re-opens with its current content, size, and color; the size dropdown and color swatches restyle it live while editing.
|
||||||
|
- Per-monitor DPI v2 support. Window and page re-render correctly when dragging between monitors with different scale factors.
|
||||||
|
- Zoom +/- toolbar buttons and keyboard shortcuts (Ctrl+=, Ctrl+-, Ctrl+0, Ctrl+Scroll).
|
||||||
|
- Crop tool improvements (Issue #15): editable CropBox coordinates, page range apply, TrimBox sync, rotation-aware coordinate conversion, draggable confirm bar.
|
||||||
|
- Settings persistence - window size, zoom, and fit mode saved/restored on launch (Issue #69).
|
||||||
|
- Global crash handler with structured log files and recovery dialog.
|
||||||
|
- About dialog (click the version label in the status bar).
|
||||||
|
- Authenticode install gate, downgrade protection, and pdfium.dll integrity check.
|
||||||
|
- Theme system: Dark, Light, High Contrast, Blood, Greed, and Cyanotic themes with live switching and settings panel (gear icon)
|
||||||
|
- Grid view zoom fits a whole number of pages across the window. Ctrl+Scroll steps through column counts (3, 4, 5 and up) and the grid opens at three pages across.
|
||||||
|
- Built-in print dialog with working print preview. Replaces the Windows print dialog (which showed "This app doesn't support print preview") with a themed dialog that previews each page and exposes printer, orientation, copies, and page-range (for example 1-3,5) settings.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Continuous scroll is now the default view mode for new installs.
|
||||||
|
- View mode order in Settings: Continuous, Single Page, Two-Page, Grid.
|
||||||
|
- Settings and keyboard shortcut overlay borders widened to 2px for better visibility.
|
||||||
|
- Text tool size value is now interpreted as points. A size of 14 renders and exports as roughly 14pt instead of about 5pt of internal render units.
|
||||||
|
- Placing an image now switches to the Select tool with the image selected, so you can immediately drag to reposition or use the corner handle to resize instead of the next click reopening the image picker (matching signature placement).
|
||||||
|
- Extracted SignatureStore and SearchService into Services/ with unit tests (KillerPDF.Tests).
|
||||||
|
- Encrypted PDF temp files written to `%LOCALAPPDATA%\KillerPDF\Temp\` instead of `%TEMP%`.
|
||||||
|
- Reopens last file on startup; ESC closes the app when no overlay is active (Issue #69).
|
||||||
|
- Grid view mode moved from a toolbar toggle to the Settings panel alongside Theme and Language. Four modes: Single Page, Continuous, Two-Page, Grid. Selection persists across sessions.
|
||||||
|
- Switching to Single or Two-Page view fits the page to the window, Continuous opens fit-to-width, and Grid opens at its column-fit default, rather than carrying the previous mode's zoom level.
|
||||||
|
- Annotation toolbars (text and draw size/color) now appear at the top-right under the toolbar buttons instead of the top-left.
|
||||||
|
- Four corner resize handles on placed images and signatures. Drag any corner to resize with the opposite corner held fixed. Handles are larger and render at the same on-screen size in every view mode.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Stale debug string appearing in status bar after Fit Width in single-page mode.
|
||||||
|
- Text edit box closed when changing the font size, because the size dropdown took keyboard focus and triggered a commit. Focus moving into the size or color bar no longer commits the edit.
|
||||||
|
- Crop confirm bar was scaled down with page zoom, making it unreadable at low zoom levels. Selection rectangle improvements.
|
||||||
|
- Save Flattened PDF now runs on a background thread (Issue #68).
|
||||||
|
- Cropped pages rasterize at CropBox size instead of document-wide maximum (Issue #68).
|
||||||
|
- Temp files cleaned up on close, crash, and startup.
|
||||||
|
- Undo of a document change (crop, rotate, page operations) now re-renders the active view, so a page no longer keeps showing its pre-undo state while the sidebar shows the correct version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.4.3] - 2026-06-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Encrypted PDFs (owner-restricted RC4) no longer fail with "Unexpected token 'xref'" when rotating pages. PdfSharpCore can silently produce a broken cross-reference entry after saving encrypted files; KillerPDF now pipes the file through PDFium to repair the XRef and retries the open automatically.
|
||||||
|
- Page view now fits to page after a rotation so the full rotated page is visible without manual rezoom.
|
||||||
|
- Mailto and other link annotations with visible borders (e.g. colored rectangles that looked like strikethroughs) no longer render those borders in saved PDFs. KillerPDF strips `/AP`, `/C`, and `/BS` from link annotations and sets an invisible border on save.
|
||||||
|
- Right-click a link annotation to remove it from the PDF entirely ("Remove Link from PDF"). Previously, clearing annotations only removed the KillerPDF overlay; the native PDF link remained active.
|
||||||
|
- Right-click a mailto link to copy just the email address; right-click an http/https link to copy the URL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.4.2] - 2026-06-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- PDF form filling. Interactive PDF forms now render their fields (text inputs, checkboxes, radio buttons) as live controls. Fill them in directly and save - field values are written back into the PDF.
|
||||||
|
- PDF outline (bookmark) support (Issue #63). A new OUTLINES tab in the sidebar displays the document's bookmark tree. Click any entry to jump to that page. The sidebar auto-fits its width to the longest entry on open and can be dragged wider; switching back to PAGES snaps to the pages-mode width.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Page rotation no longer reverts after saving. Rotations applied via the sidebar context menu now persist correctly through the save pipeline.
|
||||||
|
- Copied text words were out of order on PDFs where glyphs are stored in non-reading order (Issue #66). Text extraction now sorts words by position and uses a dynamic line-grouping threshold so both drag-select and Select All produce correctly ordered output.
|
||||||
|
- PDFs with malformed or non-standard XRef tables now open in read-only mode instead of showing "Invalid entry in XRef table" and failing entirely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.4.1] - 2026-05-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Page number jump box in toolbar. Type a page number and press Enter to navigate directly to that page.
|
||||||
|
- Signature auto-selects after placing so you can immediately reposition or resize without switching tools.
|
||||||
|
- Zoom to Width / Fit Page now re-applies when the window is resized.
|
||||||
|
- Middle mouse button panning. Hold middle mouse and drag to pan the view in any direction.
|
||||||
|
- Multi-page grid view toggle (toolbar button left of the zoom dropdown). Switch between seeing all pages in a scrollable grid and a focused single-page view. Defaults to grid view on open.
|
||||||
|
- Ctrl+S saves directly to the current file without a dialog. Ctrl+Shift+S opens Save As.
|
||||||
|
- Arrow key navigation: Left/Up goes to the previous page, Right/Down goes to the next page.
|
||||||
|
- Keyboard shortcut overlay. Press Ctrl+? to show a full shortcut reference. Dismiss with Escape or by clicking outside the panel.
|
||||||
|
- Crop tool improvements: corner drag handles to resize the selection after drawing without having to redraw; Enter applies the crop to the current page; Escape cancels; Remove Crop / Remove All buttons in the confirm bar clear an existing CropBox from one page or all pages.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fit to Width and Fit Page zoomed incorrectly on HiDPI (4K) displays.
|
||||||
|
- Pages appeared blurry at higher zoom levels on HiDPI displays.
|
||||||
|
- Signature position drifted after saving.
|
||||||
|
- Memory spike (6+ GB) when opening large PDFs on HiDPI displays.
|
||||||
|
- Navigating pages caused multi-second UI lag on documents with many pages.
|
||||||
|
- Scroll wheel now navigates to the previous page when scrolled to the top of a page, and to the next page when scrolled to the bottom.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-05-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Rotate page (Issue #52). Right-click any page in the sidebar to rotate it 90° clockwise or counter-clockwise. Works on multi-page selections.
|
||||||
|
- Insert Image tool (Issue #50). Click the toolbar button, then click anywhere on the page to place a PNG, JPG, BMP, GIF, or TIFF as a resizable annotation. Drag the green corner handle to resize; burned into the PDF on save.
|
||||||
|
- PDF link annotation support (Issue #47). Clicking hyperlinks and internal cross-references in a PDF now navigates to the target page or opens the URL in the default browser. Works on both the primary page and all secondary pages in multi-page grid view.
|
||||||
|
- New Blank Document (Ctrl+N, toolbar button). Creates a single blank A4 page as a new working document. Prompts to discard unsaved changes if a dirty file is open.
|
||||||
|
- Typewriter tool font size picker. When the Text tool is active, a settings bar appears showing size presets (8-72pt) and a color palette. Size and color are stored per-annotation and applied when flattening to PDF.
|
||||||
|
- Insert Blank Page. Right-clicking any page in the sidebar now shows a context menu with page-level operations: insert a blank A4 page, move up/down, extract, or delete.
|
||||||
|
- Signature resize. Placed signatures now show a green drag handle in the bottom-right corner. Dragging it scales the signature proportionally; releasing commits the new size.
|
||||||
|
- Multi-page grid view. When viewing a page, subsequent pages render as a tiled grid to the right and below, allowing context across multiple pages at once.
|
||||||
|
- Fit to Width on open. Files now auto-zoom to fill the viewer width on open instead of opening at 100% and clipping wide pages.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Scroll wheel in the main viewer no longer triggers page navigation. Previously, at low zoom levels where the page fit entirely in the viewport, every scroll tick caused a full page re-render.
|
||||||
|
- Page selection no longer flashes centered before jerking left. The layout width is now managed exclusively in the Dispatcher callback, eliminating the double layout pass that caused the visual artifact.
|
||||||
|
- "Back to TOC" and other internal links on secondary pages now navigate to the correct target instead of advancing to the next sequential page.
|
||||||
|
- Clicking an internal link now scrolls the viewer back to the top of the target page so links pointing to page tops (e.g. TOC back-links) land correctly.
|
||||||
|
- Internal PDF links now survive a merge. When merging PDFs, named destinations from the source document's catalog are resolved and rewritten as explicit page-object references in the merged document, so TOC and cross-reference links continue to work after merging.
|
||||||
|
- Multi-page grid content is now centered in the viewport instead of left-aligned. Panel width is snapped to a whole number of page-width slots so HorizontalAlignment=Center has room to work.
|
||||||
|
- Sidebar page list no longer shows empty space after the last page. The list now ends at the final page entry with no trailing dead zone.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Theme updated to match killertools.net: accent green changed from `#4ade80` to `#1ea54c`, backgrounds shifted to `#333333`/`#3a3a3a`, sidebar darkened to `#222222`, toolbar and title bar at `#222222`. Film grain overlay added to the main content area. Footer text lightened for readability.
|
||||||
|
- Sidebar scroll is now handled by an outer ScrollViewer wrapping the page list, allowing the list to size to its content rather than stretching to fill the panel height.
|
||||||
|
|
||||||
|
## [1.3.2] - 2026-05-11
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Windows Program Compatibility Assistant popup on first launch. Added an app manifest declaring Windows 10/11 compatibility, which suppresses PCA when the app writes to uninstall registry keys.
|
||||||
|
- "Set as default PDF viewer" prompt now only appears if KillerPDF is not already the default handler. Previously showed on every install/update regardless.
|
||||||
|
- "Set as default PDF viewer" prompt now uses the dark KillerDialog instead of a native Windows message box.
|
||||||
|
|
||||||
|
## [1.3.1] - 2026-05-11
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Print no longer fails with "No application is associated with the specified file for this action" on systems where Edge is the default PDF handler. Printing now uses WPF-native rendering and PrintDialog instead of the shell print verb.
|
||||||
|
- Zoom dropdown selected value no longer shows in blue - selection highlight now uses the accent green.
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-05-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Image signatures. Import a PNG, JPG, or BMP as a reusable signature instead of drawing one. Stored alongside drawn signatures and flattens into the PDF on save.
|
||||||
|
- Close File (Ctrl+W). Close the current document without quitting the app. Prompts if there are unsaved changes.
|
||||||
|
- Unsaved-changes protection. The title bar marks dirty files with `*` and prompts before closing or opening a new file with unsaved edits.
|
||||||
|
- Full-document Find. Ctrl+F search now scans the entire PDF and cycles through all matches, not just the current page.
|
||||||
|
- Zoom preset dropdown with quick presets (50%, 75%, 100%, 125%, 150%, 200%). Scroll-wheel zoom syncs the box, including non-preset levels.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Scrolling past the bottom of a page now advances to the next page; scrolling past the top goes back.
|
||||||
|
- Re-dropping a PDF onto the window after a file is already open now works correctly.
|
||||||
|
- Owner-password-protected PDFs now open correctly (previously only user-password was handled).
|
||||||
|
- Dragging the title bar while maximized now correctly restores and moves the window.
|
||||||
|
- Delete confirmation now reads "Delete 1 page?" or "Delete 2 pages?" instead of "Delete N page(s)?".
|
||||||
|
- Signature delete button showed a rectangle glyph instead of an X.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- All dialog boxes are now fully dark-themed via a custom dialog window. No more native Windows popups.
|
||||||
|
- Create Signature dialog now uses a dark custom chrome title bar with a red X close button.
|
||||||
|
- Button hover states and page thumbnail hover in the sidebar are now green instead of the default Windows blue.
|
||||||
|
- Toolbar icons overhauled: Open Folder, Close File, Move Up, Move Down, Extract Pages, and Merge PDFs all use cleaner glyphs.
|
||||||
|
|
||||||
|
## [1.2.1] - 2026-05-04
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Code signed with Certum certificate. Windows now shows a verified publisher instead of unknown.
|
||||||
|
- Cleaned up footer.
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-04-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Self-installing EXE. Running the downloaded binary now shows an Install / Run dialog. Install copies the EXE to `%LOCALAPPDATA%\Programs\KillerPDF\` (no UAC required), creates Start Menu and optional Desktop shortcuts, registers as a PDF file handler, and adds an uninstall entry to Add/Remove Programs. Uninstall self-deletes via a deferred batch file. Running a newer version from outside the install path shows an Update prompt instead.
|
||||||
|
- Command-line file argument support so file associations work: `KillerPDF.exe "file.pdf"` opens the file directly.
|
||||||
|
- Password-protected PDF support. Opening an encrypted PDF now prompts for the password instead of showing a generic error. The decrypted copy is held in a temp file for the session so all rendering and editing works normally.
|
||||||
|
- Save Flattened PDF (photo icon in toolbar). Rasterizes every page at 150 DPI via PDFium and writes them as embedded images into a new PDF, producing a fully uneditable document. Pending annotations are burned in before rasterization.
|
||||||
|
|
||||||
|
## [1.1.1] - 2026-04-18
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Maximize no longer covers the Windows taskbar. Added a `WM_GETMINMAXINFO` hook so the frameless window clamps to the monitor's work area (multi-monitor aware).
|
||||||
|
- Two `CS8602` nullability warnings in the font-name cleanup path.
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-04-16
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Retargeted from .NET 8 to .NET Framework 4.8 so end users no longer need to install a separate .NET runtime.
|
||||||
|
- Forced 64-bit build via `PlatformTarget=x64`.
|
||||||
|
- Added PolySharp polyfills for modern C# language features on net48.
|
||||||
|
- Replaced `Math.Clamp` calls with `Math.Min`/`Math.Max` equivalents.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Post-publish MSBuild target that automatically bundles a GPL3-compliant source zip alongside the published EXE.
|
||||||
|
- CHANGELOG.md.
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using System;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Animation;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// SHARED FADE / SLIDE
|
||||||
|
//
|
||||||
|
// One place for the timing and easing every surface animates with, so the main window, the
|
||||||
|
// dialogs, the overlays and the rail flyouts all appear the same way. An app that hand-rolls a
|
||||||
|
// DoubleAnimation per call site ends up with four durations and three easings, which reads as
|
||||||
|
// four different products.
|
||||||
|
//
|
||||||
|
// THIS IS THE CANONICAL COPY (consolidated 2026-08-08). Every app carries a byte-identical
|
||||||
|
// copy of this file - only the namespace line differs - so a diff against this file IS the
|
||||||
|
// drift check. It replaced five copies that had each grown a different subset: the kit shipped
|
||||||
|
// no fade-out at all, so KillerNotes invented FadeOutAndClose and KillerPDF invented
|
||||||
|
// FadeOut(element, done) independently, while KillerScan, KillerShell and Killendar closed
|
||||||
|
// every dialog with no fade. When something here needs to change, change it HERE first, then
|
||||||
|
// re-copy into every app.
|
||||||
|
//
|
||||||
|
// COPY THIS FILE INTO THE APP and change the namespace - it is a plain static helper with no
|
||||||
|
// dependencies. Usage:
|
||||||
|
//
|
||||||
|
// Loaded += (_, _) => Anim.FadeIn(RootBorder); // a dialog, on its root Border
|
||||||
|
// Anim.SlideInX(flyout, -12); // a rail flyout, gliding out of the rail
|
||||||
|
//
|
||||||
|
// The dialog's root Border must start at Opacity="0" in XAML, or the first frame paints solid
|
||||||
|
// before the animation takes over and the fade is a flicker rather than a fade.
|
||||||
|
//
|
||||||
|
// Two fade-outs, for two shapes of close:
|
||||||
|
// - FadeOutAndClose(window, ref flag): call from an OnClosing override; it cancels that close,
|
||||||
|
// fades the whole window, then closes for real. The default for a Window.
|
||||||
|
// - FadeOut(element, done): fades a named element and runs a callback. For a dialog that must
|
||||||
|
// hold its DialogResult until after the fade: assigning DialogResult is itself a close
|
||||||
|
// request, and WPF resets DialogResult to null whenever a close is canceled, so such a
|
||||||
|
// dialog records the result, fades, and assigns it in the callback (see KillerPDF's
|
||||||
|
// FileDialog.OnClosing).
|
||||||
|
// ============================================================
|
||||||
|
namespace KillerPDF.Controls
|
||||||
|
{
|
||||||
|
internal static class Anim
|
||||||
|
{
|
||||||
|
/// <summary>Standard fade duration in milliseconds, shared by all surfaces.</summary>
|
||||||
|
public const int FadeMs = 150;
|
||||||
|
|
||||||
|
/// <summary>Fades an element's opacity from 0 to 1 over FadeMs with an ease-out curve.</summary>
|
||||||
|
public static void FadeIn(UIElement element)
|
||||||
|
{
|
||||||
|
element.BeginAnimation(UIElement.OpacityProperty,
|
||||||
|
new DoubleAnimation(0, 1, new Duration(TimeSpan.FromMilliseconds(FadeMs)))
|
||||||
|
{
|
||||||
|
EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fades an element out to 0 and calls <paramref name="done"/> when it lands.
|
||||||
|
/// EaseIn mirrors FadeIn's EaseOut, so the surface accelerates away as smoothly as it
|
||||||
|
/// arrived. Windows use this to fade before actually closing; without it a dialog that
|
||||||
|
/// fades in vanishes instantly, which reads as a glitch.</summary>
|
||||||
|
public static void FadeOut(UIElement element, Action done)
|
||||||
|
{
|
||||||
|
var a = new DoubleAnimation(element.Opacity, 0, new Duration(TimeSpan.FromMilliseconds(FadeMs)))
|
||||||
|
{
|
||||||
|
EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseIn }
|
||||||
|
};
|
||||||
|
// Completed fires even if the value is already 0, so the callback cannot be stranded.
|
||||||
|
a.Completed += (_, _) => done?.Invoke();
|
||||||
|
element.BeginAnimation(UIElement.OpacityProperty, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fades a window out and then closes it for real. Call from an OnClosing override, or
|
||||||
|
/// wire it to a close button; it returns true if it took over the close, in which case
|
||||||
|
/// the caller must cancel this one and do nothing else.
|
||||||
|
///
|
||||||
|
/// Driven per composition frame rather than with a DoubleAnimation, matching the palette
|
||||||
|
/// fade in each app's ThemeManager and for the same reason: Timeline-based animation is
|
||||||
|
/// suppressed outright in some environments (remote sessions, "show animations in
|
||||||
|
/// Windows" turned off) and fails silently when it is, which reads as a window that
|
||||||
|
/// vanishes instead of fading. A per-frame opacity write always runs.
|
||||||
|
/// </summary>
|
||||||
|
public static bool FadeOutAndClose(Window window, ref bool alreadyFaded)
|
||||||
|
{
|
||||||
|
if (alreadyFaded || !window.IsLoaded || window.Opacity <= 0.01) return false;
|
||||||
|
alreadyFaded = true;
|
||||||
|
|
||||||
|
// Release FadeIn's animation FIRST. It is a DoubleAnimation with the default
|
||||||
|
// FillBehavior.HoldEnd, so it keeps holding Opacity after it finishes - and a held
|
||||||
|
// animation outranks a local value, which means every per-frame write below would be
|
||||||
|
// silently discarded and the window would sit at full opacity until the timer closed it.
|
||||||
|
window.BeginAnimation(UIElement.OpacityProperty, null);
|
||||||
|
window.Opacity = 1;
|
||||||
|
|
||||||
|
var clock = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
double from = window.Opacity;
|
||||||
|
EventHandler? tick = null;
|
||||||
|
tick = (_, _) =>
|
||||||
|
{
|
||||||
|
double t = clock.Elapsed.TotalMilliseconds / FadeMs;
|
||||||
|
if (t >= 1)
|
||||||
|
{
|
||||||
|
CompositionTarget.Rendering -= tick;
|
||||||
|
window.Opacity = 0;
|
||||||
|
// Off the render callback before closing: tearing the window down inside a
|
||||||
|
// Rendering handler reenters composition.
|
||||||
|
//
|
||||||
|
// Hand foreground back to the owner BEFORE the teardown. This close is
|
||||||
|
// deferred to a dispatcher callback with no input message behind it, and
|
||||||
|
// Win32 is free to ignore the activation it would otherwise do for us when
|
||||||
|
// an owned window is destroyed. With an owner chain (main window -> modeless
|
||||||
|
// pad -> modal dialog) nothing reclaimed foreground on the way back out and
|
||||||
|
// the MAIN window sank behind other applications. Activating first means the
|
||||||
|
// window being destroyed is not the foreground one, so there is nothing to
|
||||||
|
// hand off. For a modal child the owner is Win32-disabled (ShowDialog
|
||||||
|
// disables the thread's windows without touching WPF's IsEnabled, so it
|
||||||
|
// cannot be tested for here) and Activate is a harmless no-op; WPF's own
|
||||||
|
// dialog teardown re-enables and reactivates that case.
|
||||||
|
window.Dispatcher.BeginInvoke(new Action(() =>
|
||||||
|
{
|
||||||
|
Window? owner = window.Owner;
|
||||||
|
if (owner != null && owner.IsVisible) owner.Activate();
|
||||||
|
window.Close();
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.Opacity = from * (1 - t * t); // quadratic ease-in, mirrors FadeIn
|
||||||
|
};
|
||||||
|
CompositionTarget.Rendering += tick;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fade plus a horizontal glide from dx px to rest (negative dx = in from
|
||||||
|
/// the left). Used by the rail flyouts so they read as sliding out of the rail.</summary>
|
||||||
|
public static void SlideInX(UIElement element, double dx)
|
||||||
|
{
|
||||||
|
var tt = new TranslateTransform(dx, 0);
|
||||||
|
element.RenderTransform = tt;
|
||||||
|
FadeIn(element);
|
||||||
|
var a = new DoubleAnimation(dx, 0, new Duration(TimeSpan.FromMilliseconds(FadeMs)))
|
||||||
|
{
|
||||||
|
EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }
|
||||||
|
};
|
||||||
|
// Clear the transform when it lands: a RenderTransform left in place on a laid-out
|
||||||
|
// element is a permanent extra composition layer for no benefit.
|
||||||
|
a.Completed += (_, _) => element.RenderTransform = null;
|
||||||
|
tt.BeginAnimation(TranslateTransform.XProperty, a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Data;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Shapes;
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A small, themed RGB color picker: saturation/value square + hue strip, RGB and HTML-hex inputs,
|
||||||
|
/// a desktop-wide crosshair eyedropper, and a row of 9 fixed swatches that double as the annotate-bar
|
||||||
|
/// palette (shared "UserSwatches" setting). Replace overwrites one slot with the current color;
|
||||||
|
/// Reset restores defaults. Opacity is left to the annotate bar's slider, so this is opaque-RGB only.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ColorPickerDialog : Window
|
||||||
|
{
|
||||||
|
public Color SelectedColor { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>True once OK committed. Callers must read THIS, not ShowDialog's return: the
|
||||||
|
/// eyedropper opens a nested modal (the capture window, owned by this dialog), and a
|
||||||
|
/// nested modal closing inside an outer one can corrupt the outer frame's result - OK set
|
||||||
|
/// DialogResult = true and ShowDialog still returned false, silently discarding the pick.
|
||||||
|
/// Proven by trace 2026-08-01: Accept -> #FFFEFEFE, PickerClosed(result=False), and every
|
||||||
|
/// "shapes draw the wrong color" report back to the purple era was this one drop.</summary>
|
||||||
|
public bool Accepted { get; private set; }
|
||||||
|
private double _h, _s = 1, _v = 1; // HSV state (h 0..360, s/v 0..1)
|
||||||
|
private bool _updating; // guards the field<->thumb<->preview sync from feedback loops
|
||||||
|
private Border _svArea = null!;
|
||||||
|
private Canvas _svThumb = null!;
|
||||||
|
private Border _hueThumb = null!;
|
||||||
|
private Rectangle _svHue = null!;
|
||||||
|
private TextBox _rBox = null!, _gBox = null!, _bBox = null!, _hexBox = null!;
|
||||||
|
private Border _newSwatch = null!;
|
||||||
|
private WrapPanel _savedRow = null!;
|
||||||
|
private Border _replaceBtn = null!;
|
||||||
|
private bool _replaceArmed; // when on, the next swatch click is overwritten, not selected
|
||||||
|
public event Action? SwatchesChanged; // raised when the shared palette is edited, so the bar can live-update
|
||||||
|
private const int SvW = 220, SvH = 170, HueW = 18;
|
||||||
|
private const int SwatchCell = 24, SwatchCols = 9, SwatchMax = 9; // one clean row of 9 fixed slots
|
||||||
|
// Shared with the annotate-bar palette: editing these swatches reconfigures the toolbar colors.
|
||||||
|
private const string SavedKey = "UserSwatches";
|
||||||
|
// First-run / Reset palette: 9 fixed slots, last one white.
|
||||||
|
private static readonly Color[] DefaultSwatches = UiKit.DefaultSwatches;
|
||||||
|
private static SolidColorBrush R(string key) => (SolidColorBrush)Application.Current.Resources[key];
|
||||||
|
private static string L(string key) => Application.Current.TryFindResource(key) as string ?? key;
|
||||||
|
public ColorPickerDialog(Window? owner, Color initial)
|
||||||
|
{
|
||||||
|
Title = "KillerPDF - " + L("Str_Color_Name");
|
||||||
|
Width = 300;
|
||||||
|
SizeToContent = SizeToContent.Height;
|
||||||
|
DialogChrome.Configure(this, owner);
|
||||||
|
UseLayoutRounding = true;
|
||||||
|
SelectedColor = initial;
|
||||||
|
(_h, _s, _v) = RgbToHsv(initial);
|
||||||
|
BuildUi();
|
||||||
|
SyncFromHsv();
|
||||||
|
KeyDown += (_, e) => { if (e.Key == Key.Escape) { DialogResult = false; Close(); } else if (e.Key == Key.Enter) Accept(); };
|
||||||
|
}
|
||||||
|
// ── UI ──────────────────────────────────────────────────────────────────
|
||||||
|
private void BuildUi()
|
||||||
|
{
|
||||||
|
var panel = new StackPanel { Margin = new Thickness(18, 14, 18, 16) };
|
||||||
|
// 98SE: the hand-rolled card (black outline + 1px AddBevels ring) never read as a
|
||||||
|
// classic window. Use the shared DialogChrome.Frame instead - the same classic caption
|
||||||
|
// bar and five-ring raised frame KillerDialog gets - and let the caption carry the
|
||||||
|
// title, so the in-panel accent heading is skipped.
|
||||||
|
if (Services.ThemeManager.Current == Services.Theme.SE98)
|
||||||
|
{
|
||||||
|
Content = DialogChrome.Frame(this, Owner, L("Str_Color_Pick"),
|
||||||
|
() => { DialogResult = false; Close(); }, panel);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var card = new Border
|
||||||
|
{
|
||||||
|
Background = R("MenuBackgroundBrush"),
|
||||||
|
BorderBrush = UiKit.Brush("DialogFrameBrush"),
|
||||||
|
BorderThickness = Application.Current.TryFindResource("DialogFrameThickness") is Thickness dft ? dft : new Thickness(1),
|
||||||
|
Padding = Application.Current.TryFindResource("DialogFramePadding") is Thickness dfp ? dfp : new Thickness(0),
|
||||||
|
CornerRadius = UiKit.RadWindow,
|
||||||
|
Margin = Application.Current.TryFindResource("DialogHaloMargin") is Thickness hm ? hm : new Thickness(14),
|
||||||
|
Effect = UiKit.ShadowDialog()
|
||||||
|
};
|
||||||
|
// Film-grain overlay so the dialog carries the same texture as the rest of the app - dimmed
|
||||||
|
// by the shared GrainOpacity so it stays subtle (was rendering at full strength before).
|
||||||
|
var root = new Grid();
|
||||||
|
if (Owner?.TryFindResource("GrainBrushShared") is Brush grain)
|
||||||
|
{
|
||||||
|
double grainOp = Owner?.TryFindResource("GrainOpacity") is double go ? go : 0.12;
|
||||||
|
root.Children.Add(new Border { Background = grain, Opacity = grainOp, CornerRadius = UiKit.RadWindow, IsHitTestVisible = false });
|
||||||
|
}
|
||||||
|
DialogChrome.AddBevels(root, Owner);
|
||||||
|
root.Children.Add(panel);
|
||||||
|
card.Child = root;
|
||||||
|
Content = card;
|
||||||
|
// Accent heading with a 1px drop shadow - the shared style for these secondary-window titles.
|
||||||
|
var title = new TextBlock
|
||||||
|
{
|
||||||
|
Text = L("Str_Color_Pick"), Foreground = R("PrimaryBrush"),
|
||||||
|
FontSize = 14, FontWeight = FontWeights.SemiBold, Margin = new Thickness(0, 0, 0, 12),
|
||||||
|
Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, BlurRadius = 2, ShadowDepth = 1, Direction = 270, Opacity = 0.7 },
|
||||||
|
Cursor = Cursors.SizeAll
|
||||||
|
};
|
||||||
|
title.MouseLeftButtonDown += (_, e) => { if (e.ButtonState == MouseButtonState.Pressed) DragMove(); };
|
||||||
|
panel.Children.Add(title);
|
||||||
|
}
|
||||||
|
// SV square + hue strip
|
||||||
|
var pickRow = new StackPanel { Orientation = Orientation.Horizontal };
|
||||||
|
_svHue = new Rectangle { Width = SvW, Height = SvH };
|
||||||
|
var svWhite = new Rectangle { Width = SvW, Height = SvH, IsHitTestVisible = false,
|
||||||
|
Fill = new LinearGradientBrush(Color.FromArgb(255, 255, 255, 255), Color.FromArgb(0, 255, 255, 255), 0) };
|
||||||
|
var svBlack = new Rectangle { Width = SvW, Height = SvH, IsHitTestVisible = false,
|
||||||
|
Fill = new LinearGradientBrush(Color.FromArgb(0, 0, 0, 0), Color.FromArgb(255, 0, 0, 0), 90) };
|
||||||
|
_svThumb = new Canvas { Width = SvW, Height = SvH, IsHitTestVisible = false };
|
||||||
|
var svDot = new Ellipse { Width = 12, Height = 12, Stroke = Brushes.White, StrokeThickness = 2, Fill = Brushes.Transparent,
|
||||||
|
Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, BlurRadius = 2, ShadowDepth = 0, Opacity = 0.8 } };
|
||||||
|
_svThumb.Children.Add(svDot);
|
||||||
|
var svGrid = new Grid { Width = SvW, Height = SvH };
|
||||||
|
svGrid.Children.Add(_svHue); svGrid.Children.Add(svWhite); svGrid.Children.Add(svBlack); svGrid.Children.Add(_svThumb);
|
||||||
|
// ClipToBounds off so the indicator dot shows fully when it sits at an edge/corner.
|
||||||
|
_svArea = new Border { Width = SvW, Height = SvH, CornerRadius = UiKit.RadControl, ClipToBounds = false,
|
||||||
|
BorderBrush = R("CardBorderBrush"), BorderThickness = new Thickness(1), Child = svGrid, Cursor = Cursors.Cross };
|
||||||
|
_svArea.MouseLeftButtonDown += (s, e) => { _svArea.CaptureMouse(); SvPick(e.GetPosition(svGrid)); };
|
||||||
|
_svArea.MouseMove += (s, e) => { if (e.LeftButton == MouseButtonState.Pressed) SvPick(e.GetPosition(svGrid)); };
|
||||||
|
_svArea.MouseLeftButtonUp += (s, e) => _svArea.ReleaseMouseCapture();
|
||||||
|
pickRow.Children.Add(_svArea);
|
||||||
|
var hueRect = new Rectangle { Width = HueW, Height = SvH, Fill = HueStripBrush() };
|
||||||
|
// Themed handle, matching the annotate-bar slider thumbs (accent fill, light outline).
|
||||||
|
_hueThumb = new Border { Width = HueW + 6, Height = 6, BorderBrush = Brushes.White, BorderThickness = new Thickness(1.5),
|
||||||
|
Background = R("PrimaryBrush"), CornerRadius = UiKit.RadControl, IsHitTestVisible = false };
|
||||||
|
var hueCanvas = new Canvas { Width = HueW + 6, Height = SvH };
|
||||||
|
Canvas.SetLeft(_hueThumb, -3);
|
||||||
|
hueCanvas.Children.Add(_hueThumb);
|
||||||
|
var hueGrid = new Grid { Margin = new Thickness(8, 0, 0, 0) };
|
||||||
|
hueGrid.Children.Add(hueRect); hueGrid.Children.Add(hueCanvas);
|
||||||
|
var hueArea = new Border { Child = hueGrid, Cursor = Cursors.SizeNS, CornerRadius = UiKit.RadControl,
|
||||||
|
BorderBrush = R("CardBorderBrush"), BorderThickness = new Thickness(1) };
|
||||||
|
hueArea.MouseLeftButtonDown += (s, e) => { hueArea.CaptureMouse(); HuePick(e.GetPosition(hueRect)); };
|
||||||
|
hueArea.MouseMove += (s, e) => { if (e.LeftButton == MouseButtonState.Pressed) HuePick(e.GetPosition(hueRect)); };
|
||||||
|
hueArea.MouseLeftButtonUp += (s, e) => hueArea.ReleaseMouseCapture();
|
||||||
|
pickRow.Children.Add(hueArea);
|
||||||
|
panel.Children.Add(pickRow);
|
||||||
|
// RGB + hex + preview + eyedropper
|
||||||
|
var inputRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 12, 0, 0) };
|
||||||
|
_newSwatch = new Border { Width = 34, Height = 34, CornerRadius = UiKit.RadControl,
|
||||||
|
BorderBrush = R("CardBorderBrush"), BorderThickness = new Thickness(1), Margin = new Thickness(0, 0, 10, 0) };
|
||||||
|
inputRow.Children.Add(_newSwatch);
|
||||||
|
_rBox = NumBox(); _gBox = NumBox(); _bBox = NumBox();
|
||||||
|
inputRow.Children.Add(FieldGroup("R", _rBox));
|
||||||
|
inputRow.Children.Add(FieldGroup("G", _gBox));
|
||||||
|
inputRow.Children.Add(FieldGroup("B", _bBox));
|
||||||
|
_eyedropBtn = new Button
|
||||||
|
{
|
||||||
|
Width = 28, Height = 22, Margin = new Thickness(8, 14, 0, 0),
|
||||||
|
Background = R("BgCanvas"), BorderBrush = R("CardBorderBrush"), BorderThickness = new Thickness(1),
|
||||||
|
// No Cursor here: the crosshair belongs to the CAPTURE window that opens on click.
|
||||||
|
// On the button it appeared on hover, before the pick had started (2026-08-01).
|
||||||
|
Content = CrosshairIcon(), ToolTip = L("Str_Color_EyedropTT"),
|
||||||
|
Template = MakeBtnTemplate()
|
||||||
|
};
|
||||||
|
// Same hover treatment as the dialog's chips (grayer fill), and RunEyedropper holds the
|
||||||
|
// armed tint + accent border for as long as the capture is live (2026-08-01).
|
||||||
|
_eyedropBtn.MouseEnter += (_, _) => { if (!_eyedropArmed) _eyedropBtn.Background = R("CardBorderBrush"); };
|
||||||
|
_eyedropBtn.MouseLeave += (_, _) => { if (!_eyedropArmed) _eyedropBtn.Background = R("BgCanvas"); };
|
||||||
|
_eyedropBtn.Click += (_, _) => RunEyedropper();
|
||||||
|
inputRow.Children.Add(_eyedropBtn);
|
||||||
|
panel.Children.Add(inputRow);
|
||||||
|
var hexRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 8, 0, 0) };
|
||||||
|
hexRow.Children.Add(new TextBlock { Text = L("Str_Color_Hex"), Foreground = R("MutedTextBrush"), FontSize = 11,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0) });
|
||||||
|
_hexBox = MakeTextBox(96);
|
||||||
|
_hexBox.MaxLength = 7;
|
||||||
|
_hexBox.LostFocus += (_, _) => CommitHex();
|
||||||
|
_hexBox.KeyDown += (_, e) => { if (e.Key == Key.Enter) CommitHex(); };
|
||||||
|
hexRow.Children.Add(_hexBox);
|
||||||
|
panel.Children.Add(hexRow);
|
||||||
|
// Swatch header: Replace (assign current color to a slot) on the left, Reset on the far right.
|
||||||
|
var swHeader = new Grid { Margin = new Thickness(0, 12, 0, 5), Width = SwatchCols * SwatchCell };
|
||||||
|
_replaceBtn = Chip(L("Str_Color_Replace"), L("Str_Color_ReplaceTT"));
|
||||||
|
_replaceBtn.HorizontalAlignment = HorizontalAlignment.Left;
|
||||||
|
_replaceBtn.MouseLeftButtonUp += (_, _) => { _replaceArmed = !_replaceArmed; UpdateReplaceChip(); RebuildSavedRow(); };
|
||||||
|
var resetBtn = Chip(L("Str_Color_Reset"), L("Str_Color_ResetTT"));
|
||||||
|
resetBtn.HorizontalAlignment = HorizontalAlignment.Right;
|
||||||
|
resetBtn.MouseLeftButtonUp += (_, _) => { StoreSaved([.. DefaultSwatches]); _replaceArmed = false; UpdateReplaceChip(); RebuildSavedRow(); SwatchesChanged?.Invoke(); };
|
||||||
|
swHeader.Children.Add(_replaceBtn);
|
||||||
|
swHeader.Children.Add(resetBtn);
|
||||||
|
panel.Children.Add(swHeader);
|
||||||
|
_savedRow = new WrapPanel { Width = SwatchCols * SwatchCell };
|
||||||
|
panel.Children.Add(_savedRow);
|
||||||
|
UpdateReplaceChip();
|
||||||
|
RebuildSavedRow();
|
||||||
|
// OK / Cancel
|
||||||
|
var btnRow = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 14, 0, 0) };
|
||||||
|
var cancel = MakeButton(L("Str_Btn_CancelDlg"), false); cancel.Click += (_, _) => { DialogResult = false; Close(); }; cancel.IsCancel = true;
|
||||||
|
var ok = MakeButton(L("Str_Btn_OK"), true); ok.Margin = new Thickness(8, 0, 0, 0); ok.Click += (_, _) => Accept(); ok.IsDefault = true;
|
||||||
|
btnRow.Children.Add(cancel); btnRow.Children.Add(ok);
|
||||||
|
panel.Children.Add(btnRow);
|
||||||
|
}
|
||||||
|
private void Accept()
|
||||||
|
{
|
||||||
|
SelectedColor = HsvToRgb(_h, _s, _v);
|
||||||
|
Accepted = true;
|
||||||
|
// Best-effort only - see Accepted. Setting DialogResult can also throw once the
|
||||||
|
// nested capture modal has run, and the commit must not die with it.
|
||||||
|
try { DialogResult = true; } catch (InvalidOperationException) { }
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
// ── Interaction ─────────────────────────────────────────────────────────
|
||||||
|
private void SvPick(Point p) { _s = Clamp01(p.X / SvW); _v = Clamp01(1 - p.Y / SvH); SyncFromHsv(); }
|
||||||
|
private void HuePick(Point p) { _h = Clamp01(p.Y / SvH) * 360; SyncFromHsv(); }
|
||||||
|
private void CommitHex() { if (TryParseHex(_hexBox.Text, out Color c)) SetFromColor(c); else SyncFromHsv(); }
|
||||||
|
private void CommitRgb()
|
||||||
|
{
|
||||||
|
if (byte.TryParse(_rBox.Text, out byte r) && byte.TryParse(_gBox.Text, out byte g) && byte.TryParse(_bBox.Text, out byte b))
|
||||||
|
SetFromColor(Color.FromRgb(r, g, b));
|
||||||
|
else SyncFromHsv();
|
||||||
|
}
|
||||||
|
private void SetFromColor(Color c) { (_h, _s, _v) = RgbToHsv(c); SyncFromHsv(); }
|
||||||
|
// Push current HSV out to every control (hue background, thumbs, RGB, hex, preview).
|
||||||
|
private void SyncFromHsv()
|
||||||
|
{
|
||||||
|
if (_updating) return;
|
||||||
|
_updating = true;
|
||||||
|
var c = HsvToRgb(_h, _s, _v);
|
||||||
|
_svHue.Fill = new SolidColorBrush(HsvToRgb(_h, 1, 1));
|
||||||
|
Canvas.SetLeft((UIElement)_svThumb.Children[0], _s * SvW - 6);
|
||||||
|
Canvas.SetTop((UIElement)_svThumb.Children[0], (1 - _v) * SvH - 6);
|
||||||
|
Canvas.SetTop(_hueThumb, Math.Max(0, Math.Min(SvH - 6, _h / 360.0 * SvH - 3))); // keep the handle inside the strip
|
||||||
|
_rBox.Text = c.R.ToString(); _gBox.Text = c.G.ToString(); _bBox.Text = c.B.ToString();
|
||||||
|
_hexBox.Text = $"#{c.R:X2}{c.G:X2}{c.B:X2}";
|
||||||
|
_newSwatch.Background = new SolidColorBrush(c);
|
||||||
|
_updating = false;
|
||||||
|
}
|
||||||
|
// ── Eyedropper (desktop-wide) ───────────────────────────────────────────
|
||||||
|
private Button? _eyedropBtn;
|
||||||
|
private bool _eyedropArmed;
|
||||||
|
|
||||||
|
private void RunEyedropper()
|
||||||
|
{
|
||||||
|
// Armed look while the capture is live: accent border + selected-row tint, so the
|
||||||
|
// active state is visible even with the crosshair off in another corner of the screen.
|
||||||
|
_eyedropArmed = true;
|
||||||
|
if (_eyedropBtn != null)
|
||||||
|
{
|
||||||
|
_eyedropBtn.SetResourceReference(Button.BackgroundProperty, "RowSelectedBrush");
|
||||||
|
_eyedropBtn.SetResourceReference(Button.BorderBrushProperty, "PrimaryBrush");
|
||||||
|
}
|
||||||
|
try { RunEyedropperCore(); }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_eyedropArmed = false;
|
||||||
|
if (_eyedropBtn != null)
|
||||||
|
{
|
||||||
|
_eyedropBtn.Background = R("BgCanvas");
|
||||||
|
_eyedropBtn.BorderBrush = R("CardBorderBrush");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RunEyedropperCore()
|
||||||
|
{
|
||||||
|
var capture = new Window
|
||||||
|
{
|
||||||
|
WindowStyle = WindowStyle.None, AllowsTransparency = true, Background = new SolidColorBrush(Color.FromArgb(1, 0, 0, 0)),
|
||||||
|
ResizeMode = ResizeMode.NoResize, ShowInTaskbar = false, Topmost = true, Cursor = Cursors.Cross,
|
||||||
|
Left = SystemParameters.VirtualScreenLeft, Top = SystemParameters.VirtualScreenTop,
|
||||||
|
Width = SystemParameters.VirtualScreenWidth, Height = SystemParameters.VirtualScreenHeight, Owner = this
|
||||||
|
};
|
||||||
|
capture.MouseLeftButtonDown += (_, _) =>
|
||||||
|
{
|
||||||
|
// GetCursorPos returns physical screen pixels; the desktop DC's GetPixel uses the same
|
||||||
|
// space, so this is correct regardless of per-monitor DPI scaling.
|
||||||
|
if (GetCursorPos(out POINT pt))
|
||||||
|
{
|
||||||
|
IntPtr dc = GetDC(IntPtr.Zero);
|
||||||
|
uint cref = GetPixel(dc, pt.X, pt.Y);
|
||||||
|
ReleaseDC(IntPtr.Zero, dc);
|
||||||
|
capture.DialogResult = true; capture.Close();
|
||||||
|
SetFromColor(Color.FromRgb((byte)(cref & 0xFF), (byte)((cref >> 8) & 0xFF), (byte)((cref >> 16) & 0xFF)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
capture.DialogResult = false; capture.Close();
|
||||||
|
};
|
||||||
|
capture.KeyDown += (_, e) => { if (e.Key == Key.Escape) { capture.DialogResult = false; capture.Close(); } };
|
||||||
|
capture.ShowDialog();
|
||||||
|
}
|
||||||
|
// ── Saved swatches ──────────────────────────────────────────────────────
|
||||||
|
private List<Color> LoadSaved()
|
||||||
|
{
|
||||||
|
var raw = App.GetSetting(SavedKey);
|
||||||
|
if (string.IsNullOrWhiteSpace(raw)) return [.. DefaultSwatches]; // first run = defaults
|
||||||
|
var list = new List<Color>();
|
||||||
|
foreach (var part in raw!.Split(','))
|
||||||
|
if (TryParseHex(part.Trim(), out Color c)) list.Add(c);
|
||||||
|
return list.Count > 0 ? list : [.. DefaultSwatches];
|
||||||
|
}
|
||||||
|
private void StoreSaved(List<Color> list) =>
|
||||||
|
App.SetSetting(SavedKey, string.Join(",", list.Take(SwatchMax).Select(c => $"#{c.R:X2}{c.G:X2}{c.B:X2}")));
|
||||||
|
private void UpdateReplaceChip()
|
||||||
|
{
|
||||||
|
if (_replaceBtn is null) return;
|
||||||
|
_replaceBtn.Background = _replaceArmed ? R("RowSelectedBrush") : R("PaneBrush");
|
||||||
|
_replaceBtn.SetResourceReference(Border.BorderBrushProperty, _replaceArmed ? "PrimaryBrush" : "CardBorderBrush");
|
||||||
|
}
|
||||||
|
private void RebuildSavedRow()
|
||||||
|
{
|
||||||
|
_savedRow.Children.Clear();
|
||||||
|
var saved = LoadSaved().Take(SwatchMax).ToList();
|
||||||
|
for (int i = 0; i < saved.Count; i++)
|
||||||
|
{
|
||||||
|
var c = saved[i];
|
||||||
|
int idx = i;
|
||||||
|
var sw = new Border { Width = 20, Height = 20, CornerRadius = UiKit.RadControl, Margin = new Thickness(0, 0, 4, 4),
|
||||||
|
Background = new SolidColorBrush(c), BorderThickness = new Thickness(_replaceArmed ? 2 : 1), Cursor = Cursors.Hand,
|
||||||
|
ToolTip = _replaceArmed ? L("Str_Color_SwatchSetTT") : L("Str_Color_SwatchUseTT") };
|
||||||
|
if (_replaceArmed) sw.SetResourceReference(Border.BorderBrushProperty, "PrimaryBrush"); else sw.BorderBrush = R("CardBorderBrush");
|
||||||
|
sw.MouseLeftButtonUp += (_, _) =>
|
||||||
|
{
|
||||||
|
if (_replaceArmed)
|
||||||
|
{
|
||||||
|
var list = LoadSaved();
|
||||||
|
if (idx < list.Count) { list[idx] = HsvToRgb(_h, _s, _v); StoreSaved(list); }
|
||||||
|
_replaceArmed = false; UpdateReplaceChip(); RebuildSavedRow(); SwatchesChanged?.Invoke();
|
||||||
|
}
|
||||||
|
else SetFromColor(c);
|
||||||
|
};
|
||||||
|
_savedRow.Children.Add(sw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ── Small themed control builders ───────────────────────────────────────
|
||||||
|
private StackPanel FieldGroup(string label, TextBox box)
|
||||||
|
{
|
||||||
|
var sp = new StackPanel { Margin = new Thickness(0, 0, 6, 0) };
|
||||||
|
sp.Children.Add(new TextBlock { Text = label, Foreground = R("MutedTextBrush"), FontSize = 10, HorizontalAlignment = HorizontalAlignment.Center });
|
||||||
|
sp.Children.Add(box);
|
||||||
|
return sp;
|
||||||
|
}
|
||||||
|
private TextBox NumBox()
|
||||||
|
{
|
||||||
|
var b = MakeTextBox(34);
|
||||||
|
b.MaxLength = 3;
|
||||||
|
b.TextAlignment = TextAlignment.Center;
|
||||||
|
b.LostFocus += (_, _) => CommitRgb();
|
||||||
|
b.KeyDown += (_, e) => { if (e.Key == Key.Enter) CommitRgb(); };
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
private TextBox MakeTextBox(double width)
|
||||||
|
{
|
||||||
|
// Use the one shared field implementation. In particular, TextFieldBrush is white on
|
||||||
|
// 98SE while the document canvas is gray; a local BgCanvas field was visibly wrong.
|
||||||
|
var box = UiKit.Field(width);
|
||||||
|
box.Height = 22;
|
||||||
|
box.VerticalContentAlignment = VerticalAlignment.Center;
|
||||||
|
box.Padding = new Thickness(4, 0, 4, 0);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
// A crosshair/target glyph drawn in vectors, to match the KillerPDF look.
|
||||||
|
private UIElement CrosshairIcon()
|
||||||
|
{
|
||||||
|
var g = new Grid { Width = 14, Height = 14 };
|
||||||
|
var fg = R("TextBrush");
|
||||||
|
g.Children.Add(new Rectangle { Width = 1.4, Fill = fg, HorizontalAlignment = HorizontalAlignment.Center });
|
||||||
|
g.Children.Add(new Rectangle { Height = 1.4, Fill = fg, VerticalAlignment = VerticalAlignment.Center });
|
||||||
|
g.Children.Add(new Ellipse { Width = 8, Height = 8, Stroke = fg, StrokeThickness = 1.4,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Fill = Brushes.Transparent });
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
private Border Chip(string text, string tip)
|
||||||
|
{
|
||||||
|
var b = new Border { Height = 20, MinWidth = 22, CornerRadius = UiKit.RadControl, Cursor = Cursors.Hand,
|
||||||
|
BorderBrush = R("CardBorderBrush"), BorderThickness = new Thickness(1), Background = R("PaneBrush"),
|
||||||
|
Padding = new Thickness(6, 0, 6, 0), ToolTip = tip,
|
||||||
|
Child = new TextBlock { Text = text, Foreground = R("TextBrush"), FontSize = 11,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center } };
|
||||||
|
// Unified hover with the Cancel button (grayer fill), respecting Replace's armed highlight.
|
||||||
|
b.MouseEnter += (_, _) => { if (b != _replaceBtn || !_replaceArmed) b.Background = R("CardBorderBrush"); };
|
||||||
|
b.MouseLeave += (_, _) => { b.Background = (b == _replaceBtn && _replaceArmed) ? R("RowSelectedBrush") : R("PaneBrush"); };
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
private Button MakeButton(string text, bool primary)
|
||||||
|
{
|
||||||
|
// 98SE: the shared kit button already carries the beveled ChipFace treatment, so the
|
||||||
|
// dialog's OK / Cancel match the classic toolbar instead of the modern accent pair.
|
||||||
|
if (Services.ThemeManager.Current == Services.Theme.SE98)
|
||||||
|
{
|
||||||
|
var se = UiKit.Make(text, primary);
|
||||||
|
se.Height = 28; se.MinWidth = 74; se.Padding = new Thickness(12, 0, 12, 0);
|
||||||
|
return se;
|
||||||
|
}
|
||||||
|
var btn = new Button { Content = text, Height = 28, MinWidth = 74, Padding = new Thickness(12, 0, 12, 0),
|
||||||
|
BorderThickness = new Thickness(1), Cursor = Cursors.Hand };
|
||||||
|
var style = new Style(typeof(Button));
|
||||||
|
style.Setters.Add(new Setter(Control.TemplateProperty, MakeBtnTemplate()));
|
||||||
|
// Rest brushes match UiKit.Make's accent pair (SelectionFg on SelectionBg): the old
|
||||||
|
// PrimaryBrush-on-RowSelectedBrush pairing put accent text on an accent-tinted fill,
|
||||||
|
// which left "OK" unreadable at rest on several themes until hover repainted it (#227).
|
||||||
|
style.Setters.Add(new Setter(Control.ForegroundProperty, primary ? R("SelectionFg") : R("TextBrush")));
|
||||||
|
style.Setters.Add(new Setter(Control.BackgroundProperty, primary ? R("SelectionBg") : R("PaneBrush")));
|
||||||
|
style.Setters.Add(new Setter(Control.BorderBrushProperty, primary ? R("PrimaryBrush") : R("CardBorderBrush")));
|
||||||
|
// Hover: OK fills solid accent (OnPrimaryBrush text for contrast); Cancel goes a shade grayer.
|
||||||
|
var hover = new Trigger { Property = UIElement.IsMouseOverProperty, Value = true };
|
||||||
|
if (primary)
|
||||||
|
{
|
||||||
|
hover.Setters.Add(new Setter(Control.BackgroundProperty, R("PrimaryBrush")));
|
||||||
|
hover.Setters.Add(new Setter(Control.ForegroundProperty, R("OnPrimaryBrush")));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
hover.Setters.Add(new Setter(Control.BackgroundProperty, R("CardBorderBrush")));
|
||||||
|
}
|
||||||
|
style.Triggers.Add(hover);
|
||||||
|
btn.Style = style;
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
private static ControlTemplate MakeBtnTemplate()
|
||||||
|
{
|
||||||
|
var bf = new FrameworkElementFactory(typeof(Border));
|
||||||
|
foreach (var (dp, prop) in new[] { (Border.BackgroundProperty, "Background"), (Border.BorderBrushProperty, "BorderBrush"), (Border.BorderThicknessProperty, "BorderThickness") })
|
||||||
|
bf.SetBinding(dp, new Binding(prop) { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
|
||||||
|
bf.SetValue(Border.CornerRadiusProperty, UiKit.RadControl);
|
||||||
|
var cp = new FrameworkElementFactory(typeof(ContentPresenter));
|
||||||
|
cp.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center);
|
||||||
|
cp.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center);
|
||||||
|
bf.AppendChild(cp);
|
||||||
|
return new ControlTemplate(typeof(Button)) { VisualTree = bf };
|
||||||
|
}
|
||||||
|
private static LinearGradientBrush HueStripBrush()
|
||||||
|
{
|
||||||
|
var g = new LinearGradientBrush { StartPoint = new Point(0, 0), EndPoint = new Point(0, 1) };
|
||||||
|
for (int i = 0; i <= 6; i++) g.GradientStops.Add(new GradientStop(HsvToRgb(i * 60, 1, 1), i / 6.0));
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
// ── Color math / parsing ───────────────────────────────────────────────
|
||||||
|
private static double Clamp01(double v) => Math.Max(0, Math.Min(1, v));
|
||||||
|
private static bool TryParseHex(string? s, out Color c)
|
||||||
|
{
|
||||||
|
c = Colors.Black;
|
||||||
|
if (string.IsNullOrWhiteSpace(s)) return false;
|
||||||
|
s = s!.Trim().TrimStart('#');
|
||||||
|
if (s.Length == 3) s = string.Concat(s.Select(ch => $"{ch}{ch}"));
|
||||||
|
if (s.Length != 6) return false;
|
||||||
|
if (!int.TryParse(s, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int v)) return false;
|
||||||
|
c = Color.FromRgb((byte)((v >> 16) & 0xFF), (byte)((v >> 8) & 0xFF), (byte)(v & 0xFF));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
private static (double h, double s, double v) RgbToHsv(Color c)
|
||||||
|
{
|
||||||
|
double r = c.R / 255.0, g = c.G / 255.0, b = c.B / 255.0;
|
||||||
|
double max = Math.Max(r, Math.Max(g, b)), min = Math.Min(r, Math.Min(g, b)), d = max - min;
|
||||||
|
double h = 0;
|
||||||
|
if (d > 0.00001)
|
||||||
|
{
|
||||||
|
if (max == r) h = 60 * (((g - b) / d) % 6);
|
||||||
|
else if (max == g) h = 60 * (((b - r) / d) + 2);
|
||||||
|
else h = 60 * (((r - g) / d) + 4);
|
||||||
|
}
|
||||||
|
if (h < 0) h += 360;
|
||||||
|
double s = max <= 0 ? 0 : d / max;
|
||||||
|
return (h, s, max);
|
||||||
|
}
|
||||||
|
private static Color HsvToRgb(double h, double s, double v)
|
||||||
|
{
|
||||||
|
h = ((h % 360) + 360) % 360;
|
||||||
|
double c = v * s, x = c * (1 - Math.Abs((h / 60.0 % 2) - 1)), m = v - c;
|
||||||
|
double r, g, b;
|
||||||
|
if (h < 60) { r = c; g = x; b = 0; }
|
||||||
|
else if (h < 120) { r = x; g = c; b = 0; }
|
||||||
|
else if (h < 180) { r = 0; g = c; b = x; }
|
||||||
|
else if (h < 240) { r = 0; g = x; b = c; }
|
||||||
|
else if (h < 300) { r = x; g = 0; b = c; }
|
||||||
|
else { r = c; g = 0; b = x; }
|
||||||
|
return Color.FromRgb((byte)Math.Round((r + m) * 255), (byte)Math.Round((g + m) * 255), (byte)Math.Round((b + m) * 255));
|
||||||
|
}
|
||||||
|
[StructLayout(LayoutKind.Sequential)] private struct POINT { public int X; public int Y; }
|
||||||
|
[DllImport("user32.dll")] private static extern bool GetCursorPos(out POINT p);
|
||||||
|
[DllImport("user32.dll")] private static extern IntPtr GetDC(IntPtr hwnd);
|
||||||
|
[DllImport("user32.dll")] private static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc);
|
||||||
|
[DllImport("gdi32.dll")] private static extern uint GetPixel(IntPtr hdc, int x, int y);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
using System;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Effects;
|
||||||
|
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
// Chrome for modal dialog windows: Configure (borderless window setup), Frame (the rounded card +
|
||||||
|
// title bar + grain), and BuildTitleBar (the KillerPDF wordmark + red close button).
|
||||||
|
internal static class DialogChrome
|
||||||
|
{
|
||||||
|
// Keep generated dialog captions on the same close mark as the main window.
|
||||||
|
// E711 renders noticeably smaller inside the 18x16 Win98 caption face; E8BB is
|
||||||
|
// the shared chrome glyph used by the main title bar and fills that face correctly.
|
||||||
|
public const string CloseGlyph = "";
|
||||||
|
|
||||||
|
// Brush from the owner (then app) resources, with a safe fallback so the helper never throws.
|
||||||
|
private static Brush Brush(Window? owner, string key, Brush fallback)
|
||||||
|
=> (owner?.TryFindResource(key) ?? Application.Current?.TryFindResource(key)) as Brush ?? fallback;
|
||||||
|
private static T Value<T>(Window? owner, string key, T fallback)
|
||||||
|
=> (owner?.TryFindResource(key) ?? Application.Current?.TryFindResource(key)) is T value ? value : fallback;
|
||||||
|
|
||||||
|
// Builds the title bar.
|
||||||
|
// win - the window being chromed (used for DragMove on the whole bar)
|
||||||
|
// owner - supplies the themed brushes + the ChromeCloseButton style (pass the window's owner)
|
||||||
|
// fullTitle - the complete title, e.g. "KillerPDF - Transform"; the "KillerPDF" part becomes the
|
||||||
|
// wordmark and the remainder (" - Transform") is rendered in the courier title font
|
||||||
|
// onClose - invoked when the red close button is clicked (e.g. set a result then Close())
|
||||||
|
public static Border BuildTitleBar(Window win, Window? owner, string? fullTitle, Action onClose)
|
||||||
|
{
|
||||||
|
// Transparent (not null) background so the WHOLE bar is hit-testable and acts as a drag handle.
|
||||||
|
bool caption = Value(owner, "UseDialogCaption", false);
|
||||||
|
var bar = new Border
|
||||||
|
{
|
||||||
|
Background = caption ? Brush(owner, "TitleBarBrush", Brushes.Navy) : Brushes.Transparent,
|
||||||
|
SnapsToDevicePixels = true,
|
||||||
|
UseLayoutRounding = true
|
||||||
|
};
|
||||||
|
bar.MouseLeftButtonDown += (_, e) => { if (e.ButtonState == MouseButtonState.Pressed) win.DragMove(); };
|
||||||
|
|
||||||
|
var grid = new Grid
|
||||||
|
{
|
||||||
|
// KillerNotes uses the shared title-bar inset for its dialog caption too. In
|
||||||
|
// particular, the 2px top inset keeps the 16px caption button centered in the
|
||||||
|
// 20px classic band instead of riding against its upper edge.
|
||||||
|
Margin = caption
|
||||||
|
? Value(owner, "TitleBarPadding", new Thickness(4, 2, 0, 0))
|
||||||
|
: new Thickness(0)
|
||||||
|
};
|
||||||
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||||
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
|
||||||
|
var wordmark = UiKit.WordmarkFont;
|
||||||
|
var wordmarkPdf = UiKit.WordmarkFontPdf;
|
||||||
|
|
||||||
|
// Build the wordmark row. A DropShadowEffect applied directly to text rasterizes it and
|
||||||
|
// disables ClearType, which reads as blurry. So we LAYER it instead: a blurred black duplicate
|
||||||
|
// sits behind a crisp, effect-free copy - soft shadow, sharp text. `shadow` paints the duplicate.
|
||||||
|
StackPanel BuildWordmark(bool shadow)
|
||||||
|
{
|
||||||
|
var sp = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
|
||||||
|
Brush primary = shadow ? Brushes.Black : Brush(owner, "TextBrush", Brushes.White);
|
||||||
|
Brush logo = shadow ? Brushes.Black : Brush(owner, "AccentLogo", Brushes.LimeGreen);
|
||||||
|
Brush secondary = shadow ? Brushes.Black : Brush(owner, "MutedTextBrush", Brushes.Gray);
|
||||||
|
int kp = fullTitle?.IndexOf("KillerPDF", StringComparison.Ordinal) ?? -1;
|
||||||
|
if (kp >= 0)
|
||||||
|
{
|
||||||
|
// Killer + PDF in one TextBlock so the two sizes share a baseline (cohesive wordmark).
|
||||||
|
var logoTb = new TextBlock { VerticalAlignment = VerticalAlignment.Center };
|
||||||
|
logoTb.Inlines.Add(new System.Windows.Documents.Run("Killer") { FontFamily = wordmark, FontWeight = FontWeights.Normal, FontSize = 16, Foreground = primary });
|
||||||
|
logoTb.Inlines.Add(new System.Windows.Documents.Run("PDF") { FontFamily = wordmarkPdf, FontWeight = FontWeights.Bold, FontSize = 20.8, Foreground = logo });
|
||||||
|
sp.Children.Add(logoTb);
|
||||||
|
string after = fullTitle![(kp + "KillerPDF".Length)..];
|
||||||
|
if (!string.IsNullOrEmpty(after))
|
||||||
|
sp.Children.Add(new TextBlock { Text = after, FontFamily = UiKit.MonoFont, FontSize = 14, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(4, 1, 0, 0) });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sp.Children.Add(new TextBlock { Text = fullTitle ?? "", FontFamily = UiKit.MonoFont, FontSize = 14, Foreground = primary, VerticalAlignment = VerticalAlignment.Center });
|
||||||
|
}
|
||||||
|
return sp;
|
||||||
|
}
|
||||||
|
|
||||||
|
var title = new Grid { Margin = caption ? new Thickness(0) : new Thickness(16, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center };
|
||||||
|
if (caption)
|
||||||
|
{
|
||||||
|
title.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = fullTitle ?? "KillerPDF", FontFamily = Value(owner, "ChromeFontFamily", new FontFamily("Tahoma")),
|
||||||
|
FontSize = 11, FontWeight = FontWeights.Bold,
|
||||||
|
Foreground = Brush(owner, "ChromeTextBrush", Brushes.White), VerticalAlignment = VerticalAlignment.Center
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var shadowLayer = BuildWordmark(true);
|
||||||
|
shadowLayer.Opacity = 0.5;
|
||||||
|
shadowLayer.Effect = new BlurEffect { Radius = 2 };
|
||||||
|
shadowLayer.RenderTransform = new TranslateTransform(0.7, 1.2);
|
||||||
|
title.Children.Add(shadowLayer);
|
||||||
|
title.Children.Add(BuildWordmark(false));
|
||||||
|
}
|
||||||
|
Grid.SetColumn(title, 0);
|
||||||
|
grid.Children.Add(title);
|
||||||
|
|
||||||
|
// The close glyph and its complete raised/pressed face live in ChromeCloseButton.
|
||||||
|
// Supplying another glyph/font/background here was overriding that canonical style and
|
||||||
|
// produced the off-centre X and the exposed title-bar pixel seen in classic dialogs.
|
||||||
|
var close = new Button
|
||||||
|
{
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
// The hover face is part of the card's top-right corner. Centering a 26px
|
||||||
|
// button in the 40px caption left a visible 7px strip above it.
|
||||||
|
VerticalAlignment = VerticalAlignment.Top,
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
FocusVisualStyle = null,
|
||||||
|
SnapsToDevicePixels = true,
|
||||||
|
UseLayoutRounding = true
|
||||||
|
};
|
||||||
|
if (owner?.TryFindResource("ChromeCloseButton") is Style chromeClose)
|
||||||
|
{
|
||||||
|
close.Style = chromeClose;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
close.Content = CloseGlyph;
|
||||||
|
close.FontFamily = UiKit.IconFont;
|
||||||
|
close.FontSize = 10;
|
||||||
|
close.Width = 46; close.Height = 36;
|
||||||
|
close.Foreground = Brush(owner, "DangerRed", Brushes.Red);
|
||||||
|
close.Background = Brushes.Transparent;
|
||||||
|
close.BorderThickness = new Thickness(0);
|
||||||
|
close.Cursor = Cursors.Hand;
|
||||||
|
}
|
||||||
|
close.SetResourceReference(FrameworkElement.WidthProperty, "DialogCloseWidth");
|
||||||
|
close.SetResourceReference(FrameworkElement.HeightProperty, "DialogCloseHeight");
|
||||||
|
close.SetResourceReference(FrameworkElement.MarginProperty, "DialogCaptionButtonsMargin");
|
||||||
|
// Resizable borderless dialogs use WindowChrome. Without this exemption its resize
|
||||||
|
// band wins the top-right hit test, turning the close button into a resize handle.
|
||||||
|
System.Windows.Shell.WindowChrome.SetIsHitTestVisibleInChrome(close, true);
|
||||||
|
// Get the click before the caption's DragMove handler starts its modal mouse loop.
|
||||||
|
close.PreviewMouseLeftButtonDown += (_, e) => { e.Handled = true; onClose(); };
|
||||||
|
Grid.SetColumn(close, 1);
|
||||||
|
grid.Children.Add(close);
|
||||||
|
|
||||||
|
bar.Child = grid;
|
||||||
|
return bar;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Borderless transparent window setup shared by every dialog.
|
||||||
|
public static void Configure(Window win, Window? owner, bool resizable = false, bool fade = true)
|
||||||
|
{
|
||||||
|
win.Owner = owner;
|
||||||
|
win.WindowStyle = WindowStyle.None;
|
||||||
|
win.AllowsTransparency = true;
|
||||||
|
win.Background = Brushes.Transparent;
|
||||||
|
win.ResizeMode = resizable ? ResizeMode.CanResize : ResizeMode.NoResize;
|
||||||
|
win.WindowStartupLocation = owner != null ? WindowStartupLocation.CenterOwner : WindowStartupLocation.CenterScreen;
|
||||||
|
win.FontFamily = UiKit.UiFont;
|
||||||
|
TextOptions.SetTextFormattingMode(win, TextFormattingMode.Display);
|
||||||
|
TextOptions.SetTextRenderingMode(win, TextRenderingMode.Grayscale);
|
||||||
|
if (fade) WindowFx.EnableFadeClose(win);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Border FrameRing(Window? owner, string brushKey, string thicknessKey, string? marginKey = null)
|
||||||
|
{
|
||||||
|
var ring = new Border
|
||||||
|
{
|
||||||
|
IsHitTestVisible = false,
|
||||||
|
BorderBrush = Brush(owner, brushKey, Brushes.Transparent),
|
||||||
|
BorderThickness = Value(owner, thicknessKey, new Thickness(0))
|
||||||
|
};
|
||||||
|
if (marginKey != null)
|
||||||
|
ring.Margin = Value(owner, marginKey, new Thickness(0));
|
||||||
|
return ring;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UIElement WindowFrame(Window? owner)
|
||||||
|
{
|
||||||
|
var frame = new Grid { IsHitTestVisible = false };
|
||||||
|
frame.Children.Add(FrameRing(owner, "WindowFrameBrush", "DialogWindowFrameThickness", "WindowFrameMargin"));
|
||||||
|
frame.Children.Add(FrameRing(owner, "FrameInnerLightBrush", "FrameInnerLightThickness", "FrameInnerMargin"));
|
||||||
|
frame.Children.Add(FrameRing(owner, "FrameInnerDarkBrush", "FrameInnerDarkThickness", "FrameInnerMargin"));
|
||||||
|
frame.Children.Add(FrameRing(owner, "FrameOuterLightBrush", "FrameOuterLightThickness"));
|
||||||
|
frame.Children.Add(FrameRing(owner, "FrameOuterDarkBrush", "FrameOuterDarkThickness"));
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static UIElement WrapContent(Window? owner, UIElement content)
|
||||||
|
{
|
||||||
|
var host = new Grid { Margin = Value(owner, "DialogHaloMargin", new Thickness(12)) };
|
||||||
|
var radius = Value(owner, "WindowCornerRadius", new CornerRadius(7));
|
||||||
|
host.Children.Add(new Border
|
||||||
|
{
|
||||||
|
Background = Brush(owner, "WindowFrameBrush", UiKit.Brush("MenuBackgroundBrush")),
|
||||||
|
CornerRadius = radius,
|
||||||
|
IsHitTestVisible = false,
|
||||||
|
Effect = UiKit.ShadowDialog()
|
||||||
|
});
|
||||||
|
var card = new Grid();
|
||||||
|
card.Children.Add(new Border
|
||||||
|
{
|
||||||
|
Background = Brush(owner, "BackgroundBrush", UiKit.Brush("BackgroundBrush")),
|
||||||
|
CornerRadius = radius,
|
||||||
|
Margin = Value(owner, "DialogWindowFramePadding", new Thickness(0)),
|
||||||
|
Child = content
|
||||||
|
});
|
||||||
|
card.Children.Add(WindowFrame(owner));
|
||||||
|
// The 1px window outline every dialog was missing: same DialogFrameBrush the file
|
||||||
|
// picker draws (defaults to AppBorderBrush, the main window's DWM border tone).
|
||||||
|
card.Children.Add(new Border
|
||||||
|
{
|
||||||
|
BorderBrush = Brush(owner, "DialogFrameBrush", UiKit.Brush("MenuBorderBrush")),
|
||||||
|
BorderThickness = Value(owner, "DialogFrameThickness", new Thickness(1)),
|
||||||
|
CornerRadius = radius,
|
||||||
|
IsHitTestVisible = false,
|
||||||
|
});
|
||||||
|
host.Children.Add(card);
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard dialog: content is inset from the same five-layer frame used by KillerNotes.
|
||||||
|
public static UIElement Frame(Window win, Window? owner, string title, Action onClose, UIElement body)
|
||||||
|
{
|
||||||
|
win.KeyDown += (_, e) => { if (e.Key == Key.Escape) { e.Handled = true; onClose(); } };
|
||||||
|
|
||||||
|
var card = new Border
|
||||||
|
{
|
||||||
|
// Print Preview already used BackgroundBrush directly. The shared frame used
|
||||||
|
// MenuBackgroundBrush, making every other generated window a different color.
|
||||||
|
Background = Brush(owner, "BackgroundBrush", UiKit.Brush("BackgroundBrush")),
|
||||||
|
CornerRadius = UiKit.RadWindow,
|
||||||
|
Margin = Value(owner, "WindowFramePadding", new Thickness(0))
|
||||||
|
};
|
||||||
|
|
||||||
|
var root = new DockPanel();
|
||||||
|
var titleBar = BuildTitleBar(win, owner, title, onClose);
|
||||||
|
titleBar.Height = Value(owner, "DialogTitleBarHeight", 40.0);
|
||||||
|
DockPanel.SetDock(titleBar, Dock.Top);
|
||||||
|
root.Children.Add(titleBar);
|
||||||
|
root.Children.Add(body);
|
||||||
|
|
||||||
|
var grain = (owner as MainWindow)?.GrainTexture;
|
||||||
|
if (grain != null)
|
||||||
|
{
|
||||||
|
var grid = new Grid();
|
||||||
|
double op = Application.Current?.Resources["GrainOpacity"] is double go ? go : 0.05;
|
||||||
|
grid.Children.Add(new Border
|
||||||
|
{
|
||||||
|
CornerRadius = UiKit.RadWindow, IsHitTestVisible = false, Opacity = op,
|
||||||
|
Background = new ImageBrush(grain) { TileMode = TileMode.Tile, ViewportUnits = BrushMappingMode.Absolute, Viewport = new Rect(0, 0, 256, 256), Stretch = Stretch.None }
|
||||||
|
});
|
||||||
|
grid.Children.Add(root);
|
||||||
|
card.Child = grid;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var grid = new Grid();
|
||||||
|
grid.Children.Add(root);
|
||||||
|
card.Child = grid;
|
||||||
|
}
|
||||||
|
var framedContent = card.Child!;
|
||||||
|
card.Child = null;
|
||||||
|
return WrapContent(owner, framedContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void AddBevels(Grid grid, Window? owner)
|
||||||
|
{
|
||||||
|
grid.Children.Add(new Border { IsHitTestVisible = false, BorderBrush = Brush(owner, "BevelLightBrush", Brushes.Transparent), BorderThickness = Value(owner, "BevelLightThickness", new Thickness(0)) });
|
||||||
|
grid.Children.Add(new Border { IsHitTestVisible = false, BorderBrush = Brush(owner, "BevelDarkBrush", Brushes.Transparent), BorderThickness = Value(owner, "BevelDarkThickness", new Thickness(0)) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using PdfSharpCore.Pdf;
|
||||||
|
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
// Read/edit the PDF Document Info dictionary (Title, Author, Subject, Keywords, Creator). Themed via
|
||||||
|
// DialogChrome, no preview pane. Producer/dates/structure are shown read-only.
|
||||||
|
internal sealed class DocumentInfoDialog : Window
|
||||||
|
{
|
||||||
|
private readonly PdfDocument _doc;
|
||||||
|
private TextBox _title = null!, _author = null!, _subject = null!, _keywords = null!, _creator = null!;
|
||||||
|
|
||||||
|
public bool Saved { get; private set; }
|
||||||
|
|
||||||
|
public DocumentInfoDialog(Window owner, PdfDocument doc, string? filePath)
|
||||||
|
{
|
||||||
|
_doc = doc;
|
||||||
|
Title = "KillerPDF - " + L("Str_DocInfo_Suffix");
|
||||||
|
Width = 460;
|
||||||
|
SizeToContent = SizeToContent.Height;
|
||||||
|
UseLayoutRounding = true;
|
||||||
|
DialogChrome.Configure(this, owner);
|
||||||
|
BuildUi(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildUi(string? filePath)
|
||||||
|
{
|
||||||
|
var body = new StackPanel { Margin = new Thickness(20, 6, 20, 16) };
|
||||||
|
|
||||||
|
_title = AddField(body, L("Str_DocInfo_Title"), _doc.Info.Title);
|
||||||
|
_author = AddField(body, L("Str_DocInfo_Author"), _doc.Info.Author);
|
||||||
|
_subject = AddField(body, L("Str_DocInfo_Subject"), _doc.Info.Subject);
|
||||||
|
_keywords = AddField(body, L("Str_DocInfo_Keywords"), _doc.Info.Keywords, wrap: true);
|
||||||
|
_creator = AddField(body, L("Str_DocInfo_Creator"), _doc.Info.Creator);
|
||||||
|
|
||||||
|
body.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = BuildSummary(filePath),
|
||||||
|
FontFamily = UiKit.MonoFont, FontSize = 11,
|
||||||
|
Foreground = UiKit.Brush("MutedTextBrush"),
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
Margin = new Thickness(0, 12, 0, 0)
|
||||||
|
});
|
||||||
|
|
||||||
|
var cancel = UiKit.Make(L("Str_DocInfo_Cancel"), accent: false);
|
||||||
|
cancel.Click += (_, _2) => { DialogResult = false; Close(); };
|
||||||
|
cancel.IsCancel = true; // Esc
|
||||||
|
var save = UiKit.Make(L("Str_DocInfo_Save"), accent: true);
|
||||||
|
save.Click += (_, _2) => SaveAndClose();
|
||||||
|
save.IsDefault = true; // Enter
|
||||||
|
var row = UiKit.ButtonRow(cancel, save);
|
||||||
|
row.Margin = new Thickness(0, 16, 0, 0);
|
||||||
|
body.Children.Add(row);
|
||||||
|
|
||||||
|
Content = DialogChrome.Frame(this, Owner, "KillerPDF - " + L("Str_DocInfo_Suffix"),
|
||||||
|
() => { DialogResult = false; Close(); }, body);
|
||||||
|
|
||||||
|
Loaded += (_, _2) => _title.Focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TextBox AddField(StackPanel host, string label, string? value, bool wrap = false)
|
||||||
|
{
|
||||||
|
host.Children.Add(UiKit.GroupLabel(label));
|
||||||
|
var f = UiKit.Field();
|
||||||
|
f.Text = value ?? "";
|
||||||
|
f.Margin = new Thickness(0, 0, 0, 8);
|
||||||
|
// Every field wraps and grows with its content up to a cap, then scrolls - so long titles,
|
||||||
|
// subjects, or keyword lists aren't cramped on a single line. Enter is not a newline (each value
|
||||||
|
// stays a single metadata string). The `wrap` hint just gives the long-form fields more room.
|
||||||
|
f.TextWrapping = TextWrapping.Wrap;
|
||||||
|
f.AcceptsReturn = false;
|
||||||
|
f.VerticalContentAlignment = VerticalAlignment.Top;
|
||||||
|
f.VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
|
||||||
|
f.MaxHeight = wrap ? 110 : 72; // grow up to ~5 lines (keywords) / ~3 lines (others), then scroll
|
||||||
|
host.Children.Add(f);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildSummary(string? filePath)
|
||||||
|
{
|
||||||
|
var parts = new List<string>();
|
||||||
|
string producer = ""; try { producer = _doc.Info.Producer ?? ""; } catch { }
|
||||||
|
if (producer.Length > 0) parts.Add($"Producer: {producer}");
|
||||||
|
parts.Add($"{_doc.PageCount} pages");
|
||||||
|
parts.Add($"PDF {_doc.Version / 10}.{_doc.Version % 10}");
|
||||||
|
try { var d = _doc.Info.CreationDate; if (d != default) parts.Add($"created {d:yyyy-MM-dd HH:mm}"); } catch { }
|
||||||
|
try { if (!string.IsNullOrEmpty(filePath) && File.Exists(filePath)) parts.Add($"{new FileInfo(filePath).Length / 1024.0:N0} KB"); } catch { }
|
||||||
|
return string.Join("\n", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveAndClose()
|
||||||
|
{
|
||||||
|
_doc.Info.Title = _title.Text;
|
||||||
|
_doc.Info.Author = _author.Text;
|
||||||
|
_doc.Info.Subject = _subject.Text;
|
||||||
|
_doc.Info.Keywords = _keywords.Text;
|
||||||
|
_doc.Info.Creator = _creator.Text;
|
||||||
|
Saved = true;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string L(string key) => Application.Current?.TryFindResource(key) as string ?? key;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
// Export pages as images (#132): PNG/JPEG + DPI + page range, themed via DialogChrome like
|
||||||
|
// Document Info. The destination and base file name are picked afterwards with the standard
|
||||||
|
// save dialog; pages are written as <base>-page-NNN.<ext> through the same render pipeline
|
||||||
|
// the CLI --to-image command uses (FileOperations.ExportImages_Click).
|
||||||
|
internal sealed class ExportImagesDialog : Window
|
||||||
|
{
|
||||||
|
private RadioButton _png = null!, _jpg = null!;
|
||||||
|
private TextBox _dpi = null!, _range = null!;
|
||||||
|
|
||||||
|
public bool Confirmed { get; private set; }
|
||||||
|
public bool Jpeg { get; private set; }
|
||||||
|
public double Dpi { get; private set; } = 150;
|
||||||
|
public string Range { get; private set; } = "";
|
||||||
|
|
||||||
|
/// <summary>presetRange seeds the page-range field - the Pages panel's per-page export
|
||||||
|
/// (#207) opens the same dialog scoped to the clicked page(s), still editable.</summary>
|
||||||
|
public ExportImagesDialog(Window owner, string presetRange = "")
|
||||||
|
{
|
||||||
|
Title = "KillerPDF - " + L("Str_ExportImg_Suffix");
|
||||||
|
// Width follows the caption. "Export Pages as Images" is 22 characters in en-US and
|
||||||
|
// up to 35 translated, which ran the title under the close button at a fixed 380 (#223).
|
||||||
|
MinWidth = 380;
|
||||||
|
SizeToContent = SizeToContent.WidthAndHeight;
|
||||||
|
UseLayoutRounding = true;
|
||||||
|
DialogChrome.Configure(this, owner);
|
||||||
|
BuildUi();
|
||||||
|
_range.Text = presetRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildUi()
|
||||||
|
{
|
||||||
|
var body = new StackPanel { Margin = new Thickness(20, 6, 20, 16) };
|
||||||
|
|
||||||
|
body.Children.Add(UiKit.GroupLabel(L("Str_ExportImg_Format")));
|
||||||
|
var formatRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 8) };
|
||||||
|
_png = UiKit.Radio("PNG");
|
||||||
|
_png.IsChecked = true;
|
||||||
|
_png.Margin = new Thickness(0, 0, 14, 0);
|
||||||
|
_jpg = UiKit.Radio("JPEG");
|
||||||
|
formatRow.Children.Add(_png);
|
||||||
|
formatRow.Children.Add(_jpg);
|
||||||
|
body.Children.Add(formatRow);
|
||||||
|
|
||||||
|
body.Children.Add(UiKit.GroupLabel(L("Str_ExportImg_Dpi")));
|
||||||
|
_dpi = UiKit.Field();
|
||||||
|
_dpi.Text = "150";
|
||||||
|
_dpi.Margin = new Thickness(0, 0, 0, 8);
|
||||||
|
body.Children.Add(_dpi);
|
||||||
|
|
||||||
|
body.Children.Add(UiKit.GroupLabel(L("Str_Stamp_Pages")));
|
||||||
|
_range = UiKit.Field();
|
||||||
|
_range.ToolTip = L("Str_Crop_RangeTip");
|
||||||
|
_range.Margin = new Thickness(0, 0, 0, 8);
|
||||||
|
body.Children.Add(_range);
|
||||||
|
|
||||||
|
var cancel = UiKit.Make(L("Str_Tf_Cancel"), accent: false);
|
||||||
|
cancel.Click += (_, _2) => { Confirmed = false; Close(); };
|
||||||
|
cancel.IsCancel = true; // Esc
|
||||||
|
var export = UiKit.Make(L("Str_ExportImg_Export"), accent: true);
|
||||||
|
export.Click += (_, _2) => Commit();
|
||||||
|
export.IsDefault = true; // Enter
|
||||||
|
var row = UiKit.ButtonRow(cancel, export);
|
||||||
|
row.Margin = new Thickness(0, 8, 0, 0);
|
||||||
|
body.Children.Add(row);
|
||||||
|
|
||||||
|
Content = DialogChrome.Frame(this, Owner, "KillerPDF - " + L("Str_ExportImg_Suffix"),
|
||||||
|
() => { Confirmed = false; Close(); }, body);
|
||||||
|
|
||||||
|
Loaded += (_, _2) => _dpi.Focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Commit()
|
||||||
|
{
|
||||||
|
Jpeg = _jpg.IsChecked == true;
|
||||||
|
// Same accepted DPI window as the CLI (24-1200); anything unparsable falls back to 150.
|
||||||
|
Dpi = double.TryParse(_dpi.Text.Trim(), out double d) && d >= 24 && d <= 1200 ? d : 150;
|
||||||
|
Range = _range.Text.Trim();
|
||||||
|
Confirmed = true;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string L(string key) => Application.Current?.TryFindResource(key) as string ?? key;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,550 @@
|
|||||||
|
<!-- Themed replacement for Microsoft.Win32.OpenFileDialog / SaveFileDialog.
|
||||||
|
Same chrome, places rail, view modes and sortable columns as FolderPickerDialog - the row
|
||||||
|
styles and templates are shared from Controls.xaml - plus a file name box, a filter combo
|
||||||
|
and Open/Save behavior. The property surface deliberately mirrors the Win32 dialogs
|
||||||
|
(Title, Filter, FilterIndex, FileName, InitialDirectory, DefaultExt, ...) so a call site
|
||||||
|
changes by one word. -->
|
||||||
|
<Window x:Class="KillerPDF.Controls.FileDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:kui="clr-namespace:KillerPDF.Controls"
|
||||||
|
Title="KillerPDF"
|
||||||
|
Width="720" Height="520" MinWidth="560" MinHeight="420"
|
||||||
|
WindowStyle="None" ResizeMode="CanResize" ShowInTaskbar="False"
|
||||||
|
AllowsTransparency="True"
|
||||||
|
Background="Transparent"
|
||||||
|
TextOptions.TextFormattingMode="Display"
|
||||||
|
TextOptions.TextRenderingMode="ClearType"
|
||||||
|
UseLayoutRounding="True"
|
||||||
|
SnapsToDevicePixels="True"
|
||||||
|
WindowStartupLocation="CenterOwner">
|
||||||
|
|
||||||
|
<!-- The picker's styles are merged HERE, not into App.xaml. LocaleManager owns application
|
||||||
|
merged-dictionary slot [2] - it assigns the locale override there and, for English,
|
||||||
|
REMOVES it - so anything parked at [2] is deleted at startup. That is what "Cannot find
|
||||||
|
resource named 'DarkTextBox'" was. These styles are the picker's own, so scoping them to
|
||||||
|
the picker removes the index coupling entirely rather than competing for a slot. -->
|
||||||
|
<Window.Resources>
|
||||||
|
<ResourceDictionary Source="pack://application:,,,/Controls/PickerStyles.xaml"/>
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
|
<!-- NO shell:WindowChrome. On an AllowsTransparency window it fills its own non-client area,
|
||||||
|
which paints as a flat band all the way round the card - the "halo". It was NOT the only
|
||||||
|
difference from the other four dialogs: the code-behind also called
|
||||||
|
DwmChrome.SetRoundedCorners/SetThemeBorder, and on a layered window the DWM corner
|
||||||
|
preference composites DWM's own rounded frame around the WINDOW rect (halo included),
|
||||||
|
tinted by the border color - the band survived the WindowChrome removal because of it.
|
||||||
|
Both are gone now; no WindowChrome, no DWM calls, same as the other four.
|
||||||
|
|
||||||
|
Resize is done by hand instead, on the halo itself: Resize_MouseDown works out which edge
|
||||||
|
or corner the pointer is in and hands the drag to Windows with WM_NCLBUTTONDOWN, the same
|
||||||
|
mechanism Shell/Chrome.cs uses for the main window's grip. That needs the halo to receive
|
||||||
|
mouse input, which is why this Grid is #01000000 (alpha 1/255) rather than Transparent -
|
||||||
|
a fully transparent area gets no mouse events at all.
|
||||||
|
|
||||||
|
Two earlier attempts blamed the wrong thing. The shadow WAS also wrong - it sat on the
|
||||||
|
content Border, and this is the only dialog with a ComboBox, whose dropdown is an
|
||||||
|
AllowsTransparency Popup; a WPF Effect over a subtree containing one renders as a filled
|
||||||
|
rectangle. That is fixed below and is a real bug, it just was not this one.
|
||||||
|
(2026-07-30, four attempts) -->
|
||||||
|
<!-- RootFade, not RootBorder, is what the open/close animation drives - and it starts at 0,
|
||||||
|
the same way KillerShell's RootGrid does. The fade used to run on RootBorder alone, but
|
||||||
|
the shadow layer below is a SIBLING carrying the identical BackgroundBrush and geometry,
|
||||||
|
with no starting opacity: it slammed in solid on the first frame and only the card faded
|
||||||
|
on top of it, so the dialog read as blinking into existence rather than fading. Fading the
|
||||||
|
shared parent takes the shadow with it. (2026-07-31) -->
|
||||||
|
<Grid x:Name="RootFade" Opacity="0" Background="#01000000"
|
||||||
|
MouseMove="Resize_MouseMove" MouseLeftButtonDown="Resize_MouseDown">
|
||||||
|
|
||||||
|
<!-- Shadow layer: same geometry, NO content, not hit-testable. -->
|
||||||
|
<Border Margin="{DynamicResource DialogHaloMargin}" CornerRadius="{DynamicResource PanelCornerRadius}" Background="{DynamicResource BackgroundBrush}"
|
||||||
|
IsHitTestVisible="False">
|
||||||
|
<Border.Effect>
|
||||||
|
<!-- RenderingBias Quality: at radius 18 the default Performance bias renders the
|
||||||
|
blur at reduced resolution and scales it up, which blocks up into stair-steps
|
||||||
|
on this card's rounded corners. Same fix as App.xaml's PaneShadow. -->
|
||||||
|
<!-- Opacity follows the theme. A flat palette sets FlyoutShadowOpacity to 0, which
|
||||||
|
removes the cast entirely rather than leaving a soft halo round a hard-edged
|
||||||
|
window. Every other theme keeps the 0.6 it had. -->
|
||||||
|
<DropShadowEffect Color="Black" BlurRadius="18" ShadowDepth="3" Direction="270"
|
||||||
|
Opacity="{DynamicResource FlyoutShadowOpacity}"
|
||||||
|
RenderingBias="Quality"/>
|
||||||
|
</Border.Effect>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Corner radius follows the theme, not a hardcoded 6: a square-cornered palette was
|
||||||
|
getting a rounded card, and its square bevel then cut across the corners. -->
|
||||||
|
<Border x:Name="RootBorder" BorderBrush="{DynamicResource DialogFrameBrush}" BorderThickness="{DynamicResource DialogFrameThickness}"
|
||||||
|
Background="{DynamicResource BackgroundBrush}"
|
||||||
|
CornerRadius="{DynamicResource PanelCornerRadius}" Margin="{DynamicResource DialogHaloMargin}"
|
||||||
|
Padding="{DynamicResource DialogWindowFramePadding}">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<!-- Auto, with the height on the band itself: DialogTitleBarHeight is a Double and
|
||||||
|
a RowDefinition wants a GridLength, which will not convert. -->
|
||||||
|
<RowDefinition Height="Auto"/> <!-- title bar -->
|
||||||
|
<RowDefinition Height="Auto"/> <!-- heading -->
|
||||||
|
<RowDefinition Height="Auto"/> <!-- path row -->
|
||||||
|
<RowDefinition Height="*"/> <!-- places | entries -->
|
||||||
|
<RowDefinition Height="Auto"/> <!-- file name + filter -->
|
||||||
|
<RowDefinition Height="Auto"/> <!-- footer -->
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Shared film grain over the whole dialog surface -->
|
||||||
|
<Border Grid.RowSpan="6" IsHitTestVisible="False"
|
||||||
|
Background="{DynamicResource GrainTileBrush}" Opacity="{DynamicResource GrainOpacity}"/>
|
||||||
|
|
||||||
|
<!-- Title bar. TitleBarBrush, not Transparent: the row already spans the card, so this
|
||||||
|
gives it a real caption band - a gradient on the themes that define one, and
|
||||||
|
identical to BackgroundBrush on the themes that do not, which is why the other
|
||||||
|
palettes look unchanged. -->
|
||||||
|
<Border Grid.Row="0" Height="{DynamicResource DialogTitleBarHeight}"
|
||||||
|
Background="{DynamicResource DialogTitleBarBrush}" MouseLeftButtonDown="TitleBar_MouseLeftButtonDown">
|
||||||
|
<Grid>
|
||||||
|
<!-- The caption band paints OVER the dialog-wide grain (declared before this row),
|
||||||
|
so it carries its own tile - same as the main window's chrome. -->
|
||||||
|
<Border IsHitTestVisible="False"
|
||||||
|
Background="{DynamicResource GrainTileBrush}" Opacity="{DynamicResource GrainOpacity}"/>
|
||||||
|
<Grid Margin="{DynamicResource TitleBarPadding}">
|
||||||
|
<!-- A file picker caption names the operation. It is window chrome, not a
|
||||||
|
branding surface; the caller's Title belongs here and nowhere else. -->
|
||||||
|
<TextBlock Text="{Binding Title, RelativeSource={RelativeSource AncestorType={x:Type Window}}}"
|
||||||
|
FontFamily="{DynamicResource ChromeFontFamily}"
|
||||||
|
FontSize="{DynamicResource MenuFontSize}" FontWeight="Bold"
|
||||||
|
Foreground="{DynamicResource ChromeTextBrush}"
|
||||||
|
VerticalAlignment="Center" IsHitTestVisible="False"/>
|
||||||
|
<Button x:Name="CaptionCloseButton" Content="" Click="Cancel_Click"
|
||||||
|
HorizontalAlignment="Right" VerticalAlignment="Top"
|
||||||
|
Width="{DynamicResource DialogCloseWidth}" Height="{DynamicResource DialogCloseHeight}"
|
||||||
|
Margin="{DynamicResource DialogCaptionButtonsMargin}"
|
||||||
|
FontSize="10" FontFamily="Segoe MDL2 Assets"
|
||||||
|
Foreground="{DynamicResource CaptionCloseBrush}"
|
||||||
|
Background="Transparent" BorderThickness="0" Cursor="Hand"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Path row: up one level + current path + view modes -->
|
||||||
|
<Grid Grid.Row="2" Margin="16,8,16,8">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Button x:Name="UpButton" Grid.Column="0" Content="" Click="Up_Click"
|
||||||
|
ToolTip="{DynamicResource Str_TT_Up}" Style="{StaticResource PickerViewBtn}"
|
||||||
|
Margin="0,0,6,0"/>
|
||||||
|
<!-- Explicit style: an unstyled TextBox falls back to WPF's white fill and blue
|
||||||
|
focus border. The chevron overlays the box's right edge (the box pads 28 to
|
||||||
|
clear it) and drops the recent-locations list, like Explorer's address bar. -->
|
||||||
|
<Grid Grid.Column="1">
|
||||||
|
<TextBox x:Name="PathBox" Height="28" Style="{StaticResource DarkTextBox}"
|
||||||
|
FontFamily="Consolas" FontSize="12" Padding="8,0,28,0"
|
||||||
|
VerticalContentAlignment="Center" KeyDown="PathBox_KeyDown"/>
|
||||||
|
<Button x:Name="RecentsBtn" Content="{DynamicResource ComboChevGlyph}" Click="RecentsBtn_Click"
|
||||||
|
ToolTip="{DynamicResource Str_TT_RecentLocations}"
|
||||||
|
Style="{StaticResource PickerComboArrowBtn}"
|
||||||
|
HorizontalAlignment="Right" Margin="0,0,1,0"/>
|
||||||
|
<!-- Recent locations. Same raised-surface pattern as everything else:
|
||||||
|
shadow on an item-free sibling, content border on top. -->
|
||||||
|
<Popup x:Name="RecentsPopup" PlacementTarget="{Binding ElementName=PathBox}"
|
||||||
|
Placement="Bottom" StaysOpen="False" AllowsTransparency="True"
|
||||||
|
VerticalOffset="2" HorizontalOffset="-8">
|
||||||
|
<Grid Margin="8">
|
||||||
|
<Border Background="{DynamicResource FileDialogPaneBrush}" CornerRadius="{DynamicResource ControlCornerRadius}" IsHitTestVisible="False">
|
||||||
|
<Border.Effect>
|
||||||
|
<DropShadowEffect Color="Black" BlurRadius="14" ShadowDepth="2" Direction="270" Opacity="{DynamicResource FlyoutShadowOpacity}"/>
|
||||||
|
</Border.Effect>
|
||||||
|
</Border>
|
||||||
|
<Border Background="{DynamicResource FileDialogPaneBrush}" BorderBrush="{DynamicResource MenuBorderBrush}"
|
||||||
|
BorderThickness="1" CornerRadius="{DynamicResource ControlCornerRadius}">
|
||||||
|
<Grid>
|
||||||
|
<Border IsHitTestVisible="False" CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||||
|
Background="{DynamicResource GrainTileBrush}" Opacity="{DynamicResource GrainOpacity}"/>
|
||||||
|
<ListBox x:Name="RecentsList" Background="Transparent" BorderThickness="0" Padding="2,4"
|
||||||
|
Width="{Binding ActualWidth, ElementName=PathBox}" MaxHeight="260"
|
||||||
|
ItemContainerStyle="{StaticResource PickerRow}"
|
||||||
|
SelectionChanged="RecentsList_SelectionChanged"
|
||||||
|
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<TextBlock Text="{Binding}" FontFamily="Consolas" FontSize="12"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<Control Panel.ZIndex="20" Style="{StaticResource PaneBevelOverlay}"/>
|
||||||
|
</Grid>
|
||||||
|
</Popup>
|
||||||
|
</Grid>
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Margin="8,0,0,0" VerticalAlignment="Center">
|
||||||
|
<!-- E7B3 (off) / E890 (on) - KillerShell's build-proven pair (ViewOptions.cs). -->
|
||||||
|
<Button x:Name="ShowHiddenBtn" Style="{StaticResource PickerViewBtn}" Content="" ToolTip="{DynamicResource Str_TT_ShowHidden}" Click="ShowHidden_Click" Margin="0,0,6,0"/>
|
||||||
|
<Button x:Name="ViewListBtn" Style="{StaticResource PickerViewBtn}" Content="" ToolTip="{DynamicResource Str_TT_ViewList}" Click="ViewList_Click"/>
|
||||||
|
<Button x:Name="ViewIconsBtn" Style="{StaticResource PickerViewBtn}" Content="" ToolTip="{DynamicResource Str_TT_ViewIcons}" Click="ViewIcons_Click" Margin="2,0"/>
|
||||||
|
<Button x:Name="ViewDetailsBtn" Style="{StaticResource PickerViewBtn}" Content="" ToolTip="{DynamicResource Str_TT_ViewDetails}" Click="ViewDetails_Click"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Main: quick places on the left, folder contents on the right -->
|
||||||
|
<Grid Grid.Row="3" Margin="16,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="170"/>
|
||||||
|
<ColumnDefinition Width="8"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition x:Name="ImagePreviewGapColumn" Width="0"/>
|
||||||
|
<ColumnDefinition x:Name="ImagePreviewColumn" Width="0"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- A lifted card matching the file list (2026-08-15, revising the 2026-07-30 flat
|
||||||
|
call - the pane carried a fill anyway, so square shadowless edges read as a
|
||||||
|
bug, not flatness). Shadow on a SEPARATE border so text keeps ClearType.
|
||||||
|
|
||||||
|
KillerShell's own arrangement, not Explorer's: the tree fills the panel and
|
||||||
|
the pinned places sit BELOW it, the same slot its favorites drawer occupies,
|
||||||
|
so the two apps read identically. Draggable divider between. -->
|
||||||
|
<Border Grid.Column="0" IsHitTestVisible="False"
|
||||||
|
Background="{DynamicResource FileDialogPaneBrush}"
|
||||||
|
CornerRadius="{DynamicResource ControlCornerRadius}">
|
||||||
|
<Border.Effect>
|
||||||
|
<DropShadowEffect Color="Black" BlurRadius="14" ShadowDepth="2" Direction="270" Opacity="{DynamicResource FlyoutShadowOpacity}"/>
|
||||||
|
</Border.Effect>
|
||||||
|
</Border>
|
||||||
|
<!-- Film grain on the places card - the card's fill covers the dialog-wide tile. -->
|
||||||
|
<Border Grid.Column="0" IsHitTestVisible="False"
|
||||||
|
CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||||
|
Background="{DynamicResource GrainTileBrush}" Opacity="{DynamicResource GrainOpacity}"/>
|
||||||
|
<Grid Grid.Column="0">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="0"/>
|
||||||
|
<RowDefinition Height="0"/>
|
||||||
|
<RowDefinition x:Name="PlacesRow" Height="*" MinHeight="56"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Control Grid.RowSpan="3" Panel.ZIndex="20" Style="{StaticResource PaneBevelOverlay}"/>
|
||||||
|
<ListBox x:Name="PlacesList" Grid.Row="2" Background="Transparent" BorderThickness="0" Padding="2,4"
|
||||||
|
ItemContainerStyle="{StaticResource PickerPlaceRow}" SelectionChanged="Places_SelectionChanged"
|
||||||
|
ContextMenuOpening="Places_ContextMenuOpening"
|
||||||
|
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<ListBox.ContextMenu>
|
||||||
|
<ContextMenu>
|
||||||
|
<MenuItem Header="{DynamicResource Str_Menu_UnpinPlace}" Click="UnpinPlace_Click">
|
||||||
|
<MenuItem.Icon><TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="12"/></MenuItem.Icon>
|
||||||
|
</MenuItem>
|
||||||
|
</ContextMenu>
|
||||||
|
</ListBox.ContextMenu>
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal" Style="{StaticResource PickerRowContent}">
|
||||||
|
<Image Source="{Binding Icon}" Width="16" Height="16" VerticalAlignment="Center"
|
||||||
|
Margin="0,0,8,0" SnapsToDevicePixels="True"
|
||||||
|
RenderOptions.BitmapScalingMode="NearestNeighbor"/>
|
||||||
|
<TextBlock Text="{Binding Label}" FontFamily="Consolas" FontSize="12" VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
<!-- KillerShell's divider verbatim (MainWindow.xaml, terms/filters split):
|
||||||
|
invisible grab band, 1px CardBorderBrush line, accent on hover and
|
||||||
|
while dragging. -->
|
||||||
|
<GridSplitter Grid.Row="1" Height="0" Visibility="Collapsed" HorizontalAlignment="Stretch" Background="Transparent"
|
||||||
|
ResizeBehavior="PreviousAndNext" ResizeDirection="Rows"
|
||||||
|
Cursor="SizeNS" ShowsPreview="False" Focusable="False">
|
||||||
|
<GridSplitter.Template>
|
||||||
|
<ControlTemplate TargetType="GridSplitter">
|
||||||
|
<Border Background="Transparent">
|
||||||
|
<Border x:Name="line" Height="1" VerticalAlignment="Center"
|
||||||
|
Background="{DynamicResource CardBorderBrush}" Margin="12,0"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="line" Property="Background" Value="{DynamicResource PrimaryBrush}"/></Trigger>
|
||||||
|
<Trigger Property="IsDragging" Value="True"><Setter TargetName="line" Property="Background" Value="{DynamicResource PrimaryBrush}"/></Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</GridSplitter.Template>
|
||||||
|
</GridSplitter>
|
||||||
|
<!-- Left margin 0, not 4: the 16px expander gutter already provides the
|
||||||
|
inset, and the extra margin pushed the root icons visibly right of the
|
||||||
|
places icons below - dead space that cost horizontal room.
|
||||||
|
(2026-07-30) -->
|
||||||
|
<TreeView x:Name="FolderTreeCtl" Grid.Row="0" Style="{StaticResource FolderTreeView}" Visibility="Collapsed"
|
||||||
|
Margin="0,3,2,4"
|
||||||
|
PreviewMouseWheel="FolderTree_PreviewMouseWheel"
|
||||||
|
TreeViewItem.Expanded="FolderTree_Expanded"
|
||||||
|
ContextMenuOpening="FolderTree_ContextMenuOpening"
|
||||||
|
SelectedItemChanged="FolderTree_SelectedItemChanged">
|
||||||
|
<TreeView.ContextMenu>
|
||||||
|
<ContextMenu>
|
||||||
|
<MenuItem Header="{DynamicResource Str_Menu_PinPlace}" Click="TreePin_Click">
|
||||||
|
<MenuItem.Icon><TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="12"/></MenuItem.Icon>
|
||||||
|
</MenuItem>
|
||||||
|
</ContextMenu>
|
||||||
|
</TreeView.ContextMenu>
|
||||||
|
<TreeView.ItemContainerStyle>
|
||||||
|
<!-- Inherits the themed template and adds the two-way state bindings,
|
||||||
|
so RevealInTree can drive the tree from code. -->
|
||||||
|
<Style TargetType="TreeViewItem" BasedOn="{StaticResource FolderTreeItem}">
|
||||||
|
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}"/>
|
||||||
|
<Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}"/>
|
||||||
|
</Style>
|
||||||
|
</TreeView.ItemContainerStyle>
|
||||||
|
<TreeView.ItemTemplate>
|
||||||
|
<HierarchicalDataTemplate DataType="{x:Type kui:FolderNode}"
|
||||||
|
ItemsSource="{Binding Children}">
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,1">
|
||||||
|
<Image Source="{Binding Icon}" Width="16" Height="16"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,6,0" SnapsToDevicePixels="True"
|
||||||
|
RenderOptions.BitmapScalingMode="NearestNeighbor"/>
|
||||||
|
<TextBlock Text="{Binding Name}" Style="{StaticResource TreeName}"
|
||||||
|
ToolTip="{Binding Path}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</HierarchicalDataTemplate>
|
||||||
|
</TreeView.ItemTemplate>
|
||||||
|
</TreeView>
|
||||||
|
<!-- Edge fades, KillerShell's tree pattern verbatim (MainWindow.xaml): the
|
||||||
|
overlay IS the surface - BackgroundBrush plus the same grain, exactly as
|
||||||
|
KillerShell's, now that the pane is flat on the card - under an opacity
|
||||||
|
mask, so rows dissolve into an exact match instead of a flat band.
|
||||||
|
Hit-test-transparent; each edge only shows while there is something PAST
|
||||||
|
it, ramped in code (SyncTreeEdgeFades). Right inset clears the
|
||||||
|
scrollbar; the bottom margin is driven from code when a horizontal bar
|
||||||
|
appears (SyncTreeFade). -->
|
||||||
|
|
||||||
|
<Border x:Name="TreeFadeTop" Grid.Row="0" Height="18" Opacity="0" Visibility="Collapsed"
|
||||||
|
VerticalAlignment="Top" Margin="0,3,14,0" IsHitTestVisible="False">
|
||||||
|
<Border.OpacityMask>
|
||||||
|
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
|
||||||
|
<GradientStop Color="#FF000000" Offset="0"/>
|
||||||
|
<GradientStop Color="#00000000" Offset="1"/>
|
||||||
|
</LinearGradientBrush>
|
||||||
|
</Border.OpacityMask>
|
||||||
|
<Grid>
|
||||||
|
<Border Background="{DynamicResource BackgroundBrush}"/>
|
||||||
|
<Border Background="{DynamicResource GrainTileBrush}" Opacity="{DynamicResource GrainOpacity}"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<Border x:Name="TreeFadeBottom" Grid.Row="0" Height="22" Opacity="0" Visibility="Collapsed"
|
||||||
|
VerticalAlignment="Bottom" Margin="0,0,14,4" IsHitTestVisible="False">
|
||||||
|
<Border.OpacityMask>
|
||||||
|
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
|
||||||
|
<GradientStop Color="#00000000" Offset="0"/>
|
||||||
|
<GradientStop Color="#FF000000" Offset="1"/>
|
||||||
|
</LinearGradientBrush>
|
||||||
|
</Border.OpacityMask>
|
||||||
|
<Grid>
|
||||||
|
<Border Background="{DynamicResource BackgroundBrush}"/>
|
||||||
|
<Border Background="{DynamicResource GrainTileBrush}" Opacity="{DynamicResource GrainOpacity}"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<!-- Same edge fades for the places list (2026-07-30): rows dissolve
|
||||||
|
at both ends unless the list is flush there. Same surface, same ramp
|
||||||
|
(SyncPlacesEdgeFades); no scrollbar lift needed - horizontal scrolling
|
||||||
|
is disabled on this list. -->
|
||||||
|
<Border x:Name="PlacesFadeTop" Grid.Row="2" Height="18" Opacity="0"
|
||||||
|
VerticalAlignment="Top" Margin="0,0,12,0" IsHitTestVisible="False">
|
||||||
|
<Border.OpacityMask>
|
||||||
|
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
|
||||||
|
<GradientStop Color="#FF000000" Offset="0"/>
|
||||||
|
<GradientStop Color="#00000000" Offset="1"/>
|
||||||
|
</LinearGradientBrush>
|
||||||
|
</Border.OpacityMask>
|
||||||
|
<Grid>
|
||||||
|
<Border Background="{DynamicResource BackgroundBrush}"/>
|
||||||
|
<Border Background="{DynamicResource GrainTileBrush}" Opacity="{DynamicResource GrainOpacity}"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<Border x:Name="PlacesFadeBottom" Grid.Row="2" Height="22" Opacity="0"
|
||||||
|
VerticalAlignment="Bottom" Margin="0,0,12,0" IsHitTestVisible="False">
|
||||||
|
<Border.OpacityMask>
|
||||||
|
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
|
||||||
|
<GradientStop Color="#00000000" Offset="0"/>
|
||||||
|
<GradientStop Color="#FF000000" Offset="1"/>
|
||||||
|
</LinearGradientBrush>
|
||||||
|
</Border.OpacityMask>
|
||||||
|
<Grid>
|
||||||
|
<Border Background="{DynamicResource BackgroundBrush}"/>
|
||||||
|
<Border Background="{DynamicResource GrainTileBrush}" Opacity="{DynamicResource GrainOpacity}"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Grid.Column="2">
|
||||||
|
<Border Background="{DynamicResource FileDialogPaneBrush}" CornerRadius="{DynamicResource ControlCornerRadius}" IsHitTestVisible="False">
|
||||||
|
<Border.Effect>
|
||||||
|
<DropShadowEffect Color="Black" BlurRadius="14" ShadowDepth="2" Direction="270" Opacity="{DynamicResource FlyoutShadowOpacity}"/>
|
||||||
|
</Border.Effect>
|
||||||
|
</Border>
|
||||||
|
<Border Background="{DynamicResource FileDialogPaneBrush}" CornerRadius="{DynamicResource ControlCornerRadius}">
|
||||||
|
<Grid>
|
||||||
|
<Border IsHitTestVisible="False" CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||||
|
Background="{DynamicResource GrainTileBrush}" Opacity="{DynamicResource GrainOpacity}"/>
|
||||||
|
<DockPanel>
|
||||||
|
<Grid DockPanel.Dock="Top" x:Name="DetailsHeader" Visibility="Collapsed" Margin="12,4,26,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="80"/>
|
||||||
|
<ColumnDefinition Width="150"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Button Grid.Column="0" Style="{StaticResource PickerColBtn}" Click="SortName_Click">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="{DynamicResource Str_Col_Name}"/>
|
||||||
|
<TextBlock x:Name="NameArrow" FontFamily="Segoe MDL2 Assets" FontSize="8" Margin="4,1,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="1" Style="{StaticResource PickerColBtn}" HorizontalContentAlignment="Right" Click="SortSize_Click">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="{DynamicResource Str_Col_Size}"/>
|
||||||
|
<TextBlock x:Name="SizeArrow" FontFamily="Segoe MDL2 Assets" FontSize="8" Margin="4,1,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="2" Style="{StaticResource PickerColBtn}" Margin="10,0,0,0" Click="SortModified_Click">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="{DynamicResource Str_Col_Modified}"/>
|
||||||
|
<TextBlock x:Name="ModArrow" FontFamily="Segoe MDL2 Assets" FontSize="8" Margin="4,1,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<Grid>
|
||||||
|
<ListBox x:Name="FileList" Background="Transparent" BorderThickness="0" Padding="2,4"
|
||||||
|
ItemContainerStyle="{StaticResource PickerRow}"
|
||||||
|
ItemTemplate="{StaticResource RowTemplate}"
|
||||||
|
ItemsPanel="{StaticResource PanelStack}"
|
||||||
|
SelectionChanged="Files_SelectionChanged" MouseDoubleClick="Files_DoubleClick"
|
||||||
|
ContextMenuOpening="Files_ContextMenuOpening"
|
||||||
|
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||||
|
ScrollViewer.CanContentScroll="False"
|
||||||
|
PreviewMouseWheel="FileList_PreviewMouseWheel">
|
||||||
|
<ListBox.ContextMenu>
|
||||||
|
<ContextMenu>
|
||||||
|
<MenuItem Header="{DynamicResource Str_Menu_PinPlace}" Click="FilePin_Click">
|
||||||
|
<MenuItem.Icon><TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="12"/></MenuItem.Icon>
|
||||||
|
</MenuItem>
|
||||||
|
</ContextMenu>
|
||||||
|
</ListBox.ContextMenu>
|
||||||
|
</ListBox>
|
||||||
|
<TextBlock x:Name="EmptyHint" Text="{DynamicResource Str_Dlg_NoMatchingFiles}" Visibility="Collapsed"
|
||||||
|
Foreground="{DynamicResource DimTextBrush}" FontFamily="Consolas" FontSize="11"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap"
|
||||||
|
TextAlignment="Center" Margin="20"/>
|
||||||
|
</Grid>
|
||||||
|
</DockPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<Control Panel.ZIndex="20" Style="{StaticResource PaneBevelOverlay}"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Image-only pickers opt into this pane. Keeping it in the shared dialog makes
|
||||||
|
Insert Image, image signatures, image stamps, and image-to-PDF import behave
|
||||||
|
identically without changing ordinary Open/Save dialogs. -->
|
||||||
|
<Grid x:Name="ImagePreviewHost" Grid.Column="4" Visibility="Collapsed">
|
||||||
|
<Border Background="{DynamicResource FileDialogPaneBrush}"
|
||||||
|
CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||||
|
IsHitTestVisible="False">
|
||||||
|
<Border.Effect>
|
||||||
|
<DropShadowEffect Color="Black" BlurRadius="14" ShadowDepth="2" Direction="270"
|
||||||
|
Opacity="{DynamicResource FlyoutShadowOpacity}"/>
|
||||||
|
</Border.Effect>
|
||||||
|
</Border>
|
||||||
|
<Border Background="{DynamicResource FileDialogPaneBrush}"
|
||||||
|
CornerRadius="{DynamicResource ControlCornerRadius}">
|
||||||
|
<Grid>
|
||||||
|
<Border IsHitTestVisible="False"
|
||||||
|
CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||||
|
Background="{DynamicResource GrainTileBrush}"
|
||||||
|
Opacity="{DynamicResource GrainOpacity}"/>
|
||||||
|
<Grid Margin="12">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<TextBlock Text="{DynamicResource Str_Dlg_Preview}"
|
||||||
|
FontFamily="Consolas" FontSize="11"
|
||||||
|
Foreground="{DynamicResource MutedTextBrush}"/>
|
||||||
|
<Grid Grid.Row="1" Margin="0,10,0,0">
|
||||||
|
<TextBlock x:Name="ImagePreviewPlaceholder" Text=""
|
||||||
|
FontFamily="Segoe MDL2 Assets" FontSize="42"
|
||||||
|
Foreground="{DynamicResource DimTextBrush}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
<Image x:Name="ImagePreview" Stretch="Uniform"
|
||||||
|
SnapsToDevicePixels="True"
|
||||||
|
RenderOptions.BitmapScalingMode="HighQuality"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<Control Panel.ZIndex="20" Style="{StaticResource PaneBevelOverlay}"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- File name + filter. Labels are DimTextBrush on the dark card: 3.15:1, the tier
|
||||||
|
these brushes are calibrated for. -->
|
||||||
|
<Grid Grid.Row="4" Margin="16,12,16,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="{DynamicResource Str_Dlg_FileName}" VerticalAlignment="Center"
|
||||||
|
Margin="0,0,10,0" Foreground="{DynamicResource MutedTextBrush}" FontFamily="Consolas" FontSize="11"/>
|
||||||
|
<!-- Left padding 5, not 8: a TextBox's content host carries its own ~2px inset
|
||||||
|
(WPF's TextBoxView), so at 8 the name started visibly right of the filter
|
||||||
|
combo's text directly below it (combo padding is 7 with no inherent inset).
|
||||||
|
5 + 2 lines the two up. (2026-07-31) -->
|
||||||
|
<TextBox x:Name="FileNameBox" Grid.Row="0" Grid.Column="1" Height="28"
|
||||||
|
Style="{StaticResource DarkTextBox}"
|
||||||
|
FontFamily="Consolas" FontSize="12" Padding="5,0,8,0"
|
||||||
|
VerticalContentAlignment="Center" KeyDown="FileNameBox_KeyDown"/>
|
||||||
|
|
||||||
|
<TextBlock x:Name="FilterLabel" Grid.Row="1" Grid.Column="0" Text="{DynamicResource Str_Dlg_FileType}" VerticalAlignment="Center"
|
||||||
|
Margin="0,8,10,0" Foreground="{DynamicResource MutedTextBrush}" FontFamily="Consolas" FontSize="11"/>
|
||||||
|
<ComboBox x:Name="FilterCombo" Grid.Row="1" Grid.Column="1" Height="28" Margin="0,8,0,0"
|
||||||
|
Style="{StaticResource DarkComboBox}"
|
||||||
|
SelectionChanged="Filter_SelectionChanged"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Footer: selection details on the left, actions on the right -->
|
||||||
|
<Grid Grid.Row="5" Margin="16,12,16,16">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0" VerticalAlignment="Center" Margin="0,0,12,0">
|
||||||
|
<TextBlock x:Name="SelName" FontFamily="Consolas" FontSize="12" Foreground="{DynamicResource TextBrush}"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
<TextBlock x:Name="SelMeta" FontFamily="Consolas" FontSize="10" Foreground="{DynamicResource MutedTextBrush}"
|
||||||
|
TextTrimming="CharacterEllipsis" Margin="0,2,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<Button Content="{DynamicResource Str_Btn_Cancel}" Style="{StaticResource SurfaceButton}" MinWidth="90" Margin="0,0,8,0" Click="Cancel_Click"/>
|
||||||
|
<Button x:Name="AcceptButton" Style="{StaticResource OutlineButton}" MinWidth="90" Click="OK_Click"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- The same directional 98SE window frame used by DialogChrome. Modern themes set these
|
||||||
|
thicknesses to zero, so their existing card treatment is unchanged. -->
|
||||||
|
<Grid Margin="{DynamicResource DialogHaloMargin}" IsHitTestVisible="False">
|
||||||
|
<Border Margin="{DynamicResource WindowFrameMargin}"
|
||||||
|
BorderBrush="{DynamicResource WindowFrameBrush}"
|
||||||
|
BorderThickness="{DynamicResource DialogWindowFrameThickness}"/>
|
||||||
|
<Border Margin="{DynamicResource FrameInnerMargin}"
|
||||||
|
BorderBrush="{DynamicResource FrameInnerLightBrush}"
|
||||||
|
BorderThickness="{DynamicResource FrameInnerLightThickness}"/>
|
||||||
|
<Border Margin="{DynamicResource FrameInnerMargin}"
|
||||||
|
BorderBrush="{DynamicResource FrameInnerDarkBrush}"
|
||||||
|
BorderThickness="{DynamicResource FrameInnerDarkThickness}"/>
|
||||||
|
<Border BorderBrush="{DynamicResource FrameOuterLightBrush}"
|
||||||
|
BorderThickness="{DynamicResource FrameOuterLightThickness}"/>
|
||||||
|
<Border BorderBrush="{DynamicResource FrameOuterDarkBrush}"
|
||||||
|
BorderThickness="{DynamicResource FrameOuterDarkThickness}"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Controls.Primitives;
|
||||||
|
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Where every flyout opens: the bottom corner of the content pane beside the rail.
|
||||||
|
/// (From KillerUI/Shell/FlyoutPlacement.cs - the family flyout standard.)
|
||||||
|
///
|
||||||
|
/// That rail-adjacent corner is the answer because of what bounds it, and all three matter:
|
||||||
|
/// - it is INSIDE the window, so a flyout never hangs over the desktop;
|
||||||
|
/// - it is ABOVE the footer, so the status bar is never covered;
|
||||||
|
/// - it is clear of the icon rail, so the rail buttons are never covered.
|
||||||
|
/// The content pane is the one element bounded by all three at once, so flyouts are positioned
|
||||||
|
/// against IT - not against the button, and not by any built-in placement mode.
|
||||||
|
///
|
||||||
|
/// WHY NOT PlacementMode.Right / Top / etc: a Popup is its own top-level window, and WPF's
|
||||||
|
/// built-in modes only ever avoid the SCREEN edge. They do not know the app window exists, let
|
||||||
|
/// alone the footer or the rail. "Right of the button" opened flyouts over the desktop when the
|
||||||
|
/// rail sat near the window's right edge; "Top" opened them over the status bar. Hours went
|
||||||
|
/// into re-tuning offsets before it was clear no built-in mode can express the requirement.
|
||||||
|
/// The requirement: do not obscure the icons, do not obscure the status bar, and put the
|
||||||
|
/// flyout against the rail-adjacent corner of the content pane. (2026-07-30)
|
||||||
|
///
|
||||||
|
/// WIRING (once, before the flyouts open):
|
||||||
|
/// FlyoutPlacement.UsePane(pane, railOnRight); // the element the document content sits on
|
||||||
|
/// then, each time a flyout opens:
|
||||||
|
/// FlyoutPlacement.Attach(themeMenu, themeButton);
|
||||||
|
/// themeMenu.IsOpen = true;
|
||||||
|
///
|
||||||
|
/// The flyout's own card carries a 6px margin for its drop shadow (FlyoutCard in
|
||||||
|
/// MainWindow.xaml), so pinning flush to the corner leaves the VISIBLE card sitting neatly just
|
||||||
|
/// inside it. Do not add an inset here.
|
||||||
|
/// </summary>
|
||||||
|
internal static class FlyoutPlacement
|
||||||
|
{
|
||||||
|
/// <summary>The content pane. Set once; every flyout positions against it.</summary>
|
||||||
|
private static FrameworkElement? _pane;
|
||||||
|
private static bool _alignRight;
|
||||||
|
|
||||||
|
internal static void UsePane(FrameworkElement pane, bool alignRight)
|
||||||
|
{
|
||||||
|
_pane = pane;
|
||||||
|
_alignRight = alignRight;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void Attach(Popup popup, UIElement _)
|
||||||
|
{
|
||||||
|
popup.PlacementTarget = _pane;
|
||||||
|
popup.Placement = PlacementMode.Custom;
|
||||||
|
popup.CustomPopupPlacementCallback =
|
||||||
|
(popupSize, targetSize, __) => PaneCorner(popupSize, targetSize, _alignRight);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void Attach(ContextMenu menu, UIElement _)
|
||||||
|
{
|
||||||
|
menu.PlacementTarget = _pane;
|
||||||
|
menu.Placement = PlacementMode.Custom;
|
||||||
|
// The shared ContextMenu style compensates ordinary pointer-anchored menus for its
|
||||||
|
// enlarged shadow halo. Rail flyouts use exact pane-corner coordinates instead, so
|
||||||
|
// clear those global offsets and account for the halo in BottomLeftOfPane.
|
||||||
|
menu.HorizontalOffset = 0;
|
||||||
|
menu.VerticalOffset = 0;
|
||||||
|
menu.CustomPopupPlacementCallback =
|
||||||
|
(popupSize, targetSize, __) => PaneCorner(popupSize, targetSize, _alignRight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Coordinates are relative to the pane's top-left. The horizontal coordinate mirrors
|
||||||
|
/// between pane edges, while y puts the flyout's bottom above the footer.
|
||||||
|
/// </summary>
|
||||||
|
internal static CustomPopupPlacement[] PaneCorner(
|
||||||
|
Size popupSize, Size targetSize, bool alignRight)
|
||||||
|
{
|
||||||
|
// ContextMenu's template now reserves 22px left, 18px top, and 26px bottom for its
|
||||||
|
// shadow. Position the VISIBLE card at the same 6px pane inset used before that halo
|
||||||
|
// grew. On the right, mirror the same inset against the pane's right edge.
|
||||||
|
double x = alignRight ? targetSize.Width - popupSize.Width + 16 : -16;
|
||||||
|
double y = targetSize.Height - popupSize.Height + 20;
|
||||||
|
|
||||||
|
// A flyout taller than the pane would otherwise start above it and run over the
|
||||||
|
// toolbar; pin it to the pane's top instead and let it use the height it has.
|
||||||
|
if (y < 0) y = 0;
|
||||||
|
|
||||||
|
return new[] { new CustomPopupPlacement(new Point(x, y), PopupPrimaryAxis.None) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Data;
|
||||||
|
using System.Windows.Media;
|
||||||
|
|
||||||
|
namespace KillerPDF.Controls
|
||||||
|
{
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// FOLDER TREE - the file dialog's left pane, below places
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// Ported from KillerShell's FolderTree.cs (the family reference), minus what a modal file
|
||||||
|
// dialog does not need: no demo mode, no expansion persistence (the dialog reveals the
|
||||||
|
// current folder on open instead), no shell context menu suite.
|
||||||
|
//
|
||||||
|
// One node per folder, children loaded only when a node is actually expanded. A tree that
|
||||||
|
// eagerly walked the disk would hang on the first drive with a deep tree on it. The lazy
|
||||||
|
// load is the standard placeholder trick: every node that might have children gets a single
|
||||||
|
// dummy child so WPF draws an expander arrow, and the real children replace it on first
|
||||||
|
// expand. "Might have children" is deliberately optimistic - proving a folder empty costs
|
||||||
|
// the very enumeration being deferred.
|
||||||
|
public sealed class FolderNode : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
private static readonly FolderNode Placeholder = new("", "", false);
|
||||||
|
|
||||||
|
/// <summary>Set by FileDialog from its persisted toggle, BEFORE the tree loads. Gates
|
||||||
|
/// attribute-Hidden/System folders AND leading-dot names, same as the file list.</summary>
|
||||||
|
internal static bool ShowHidden;
|
||||||
|
|
||||||
|
public string Path { get; }
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
// Drives get their own treatment: always expandable, never disappear mid-session, and
|
||||||
|
// their label is "Local Disk (C:)" rather than a bare folder name.
|
||||||
|
public bool IsDrive { get; }
|
||||||
|
|
||||||
|
public ObservableCollection<FolderNode> Children { get; } = [];
|
||||||
|
|
||||||
|
public FolderNode(string path, string name, bool mayHaveChildren)
|
||||||
|
{
|
||||||
|
Path = path;
|
||||||
|
Name = name;
|
||||||
|
if (mayHaveChildren) Children.Add(Placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
public FolderNode(DriveInfo d)
|
||||||
|
{
|
||||||
|
Path = d.RootDirectory.FullName;
|
||||||
|
IsDrive = true;
|
||||||
|
Name = DriveLabel(d);
|
||||||
|
Children.Add(Placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>"Local Disk (C:)" style label, or the bare letter when the volume cannot be read.</summary>
|
||||||
|
internal static string DriveLabel(DriveInfo d)
|
||||||
|
{
|
||||||
|
string letter = d.Name.TrimEnd('\\');
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// VolumeLabel throws on a drive that is not ready (empty optical, disconnected
|
||||||
|
// share), which is exactly when we still want to show the letter.
|
||||||
|
if (d.IsReady && !string.IsNullOrWhiteSpace(d.VolumeLabel))
|
||||||
|
return d.VolumeLabel + " (" + letter + ")";
|
||||||
|
}
|
||||||
|
catch (IOException) { }
|
||||||
|
catch (UnauthorizedAccessException) { }
|
||||||
|
return letter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsLoaded { get; private set; }
|
||||||
|
|
||||||
|
// A drive's REAL icon (USB, network, optical) via the real-path query; plain folders get
|
||||||
|
// the shared generic folder icon so the tree never touches the disk per row.
|
||||||
|
public ImageSource? Icon
|
||||||
|
=> IsDrive ? Services.ShellIcons.Place(Path) : Services.ShellIcons.Small(Path, true);
|
||||||
|
|
||||||
|
private bool _isExpanded;
|
||||||
|
public bool IsExpanded
|
||||||
|
{
|
||||||
|
get => _isExpanded;
|
||||||
|
set { if (_isExpanded != value) { _isExpanded = value; Raise(); } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _isSelected;
|
||||||
|
public bool IsSelected
|
||||||
|
{
|
||||||
|
get => _isSelected;
|
||||||
|
set { if (_isSelected != value) { _isSelected = value; Raise(); } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the placeholder with the real subfolders. Enumeration happens off the UI
|
||||||
|
/// thread - a slow or disconnected network drive would otherwise freeze the dialog for
|
||||||
|
/// as long as the SMB timeout takes.
|
||||||
|
/// </summary>
|
||||||
|
public async Task LoadChildrenAsync()
|
||||||
|
{
|
||||||
|
if (IsLoaded) return;
|
||||||
|
IsLoaded = true;
|
||||||
|
|
||||||
|
string path = Path;
|
||||||
|
List<FolderNode> kids = await Task.Run(() => EnumerateChildren(path)).ConfigureAwait(true);
|
||||||
|
|
||||||
|
Children.Clear();
|
||||||
|
foreach (var k in kids) Children.Add(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-enumerates this node's children in place, keeping whatever the user had open.
|
||||||
|
/// Used when the show-hidden filter changes. Reconciled IN PLACE rather than cleared
|
||||||
|
/// and refilled: Clear() removes the container holding the tree's selection, and WPF
|
||||||
|
/// answers a lost selection by selecting the PARENT node - which would navigate the
|
||||||
|
/// dialog up one folder on a toggle that has nothing to do with where you are.
|
||||||
|
/// </summary>
|
||||||
|
internal async Task RefreshAsync()
|
||||||
|
{
|
||||||
|
if (!IsLoaded) return;
|
||||||
|
|
||||||
|
string path = Path;
|
||||||
|
var fresh = await Task.Run(() => EnumerateChildren(path)).ConfigureAwait(true);
|
||||||
|
|
||||||
|
var byName = new Dictionary<string, FolderNode>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var n in fresh) byName[n.Name] = n;
|
||||||
|
|
||||||
|
for (int i = Children.Count - 1; i >= 0; i--)
|
||||||
|
if (!byName.ContainsKey(Children[i].Name)) Children.RemoveAt(i);
|
||||||
|
|
||||||
|
var have = new Dictionary<string, FolderNode>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var c in Children) have[c.Name] = c;
|
||||||
|
|
||||||
|
for (int i = 0; i < fresh.Count; i++)
|
||||||
|
{
|
||||||
|
if (have.TryGetValue(fresh[i].Name, out var existing))
|
||||||
|
{
|
||||||
|
// Anything the user has opened stays the SAME node object, so its own subtree
|
||||||
|
// and IsExpanded survive; only genuinely new entries get fresh nodes.
|
||||||
|
int at = Children.IndexOf(existing);
|
||||||
|
if (at != i) Children.Move(at, i);
|
||||||
|
await existing.RefreshAsync(); // its children are stale for the same reason
|
||||||
|
}
|
||||||
|
else Children.Insert(i, fresh[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<FolderNode> EnumerateChildren(string path)
|
||||||
|
{
|
||||||
|
var list = new List<FolderNode>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var d in new DirectoryInfo(path).EnumerateDirectories())
|
||||||
|
{
|
||||||
|
// Same gate the file list applies: attribute Hidden/System AND leading-dot
|
||||||
|
// names, so the two panes never disagree about what exists. System is grouped
|
||||||
|
// with hidden rather than given its own switch - Explorer's separate option
|
||||||
|
// guards a handful of roots nobody browses to on purpose.
|
||||||
|
if (!ShowHidden)
|
||||||
|
{
|
||||||
|
var a = d.Attributes;
|
||||||
|
if ((a & FileAttributes.Hidden) != 0 || (a & FileAttributes.System) != 0) continue;
|
||||||
|
if (d.Name.StartsWith(".", StringComparison.Ordinal)) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(new FolderNode(d.FullName, d.Name, mayHaveChildren: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException) { /* show what we can see */ }
|
||||||
|
catch (IOException) { }
|
||||||
|
|
||||||
|
list.Sort((x, y) => string.Compare(x.Name, y.Name, StringComparison.CurrentCultureIgnoreCase));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
private void Raise([CallerMemberName] string? p = null)
|
||||||
|
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when a TreeViewItem is the last child of its parent - which is the one thing the
|
||||||
|
// folder tree's connecting lines need to know. Every node draws a vertical line down its own
|
||||||
|
// left edge; for the LAST child that line has to stop at the elbow instead of running past
|
||||||
|
// the bottom of the node into empty space. There is no "IsLastItem" property in WPF and no
|
||||||
|
// way to ask in pure XAML, hence this. Bound with the item itself as the source, so it
|
||||||
|
// re-evaluates when the container is recycled onto a different node.
|
||||||
|
public sealed class LastChildConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is not DependencyObject d) return false;
|
||||||
|
|
||||||
|
var parent = ItemsControl.ItemsControlFromItemContainer(d);
|
||||||
|
if (parent == null) return false;
|
||||||
|
|
||||||
|
int index = parent.ItemContainerGenerator.IndexFromContainer(d);
|
||||||
|
return index >= 0 && index == parent.Items.Count - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Animation;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using System.Windows.Shapes;
|
||||||
|
using KillerPDF.Services;
|
||||||
|
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
// ============================================================
|
||||||
|
// Themed dialog - replaces MessageBox for dark-UI consistency
|
||||||
|
// ============================================================
|
||||||
|
internal static class KillerDialog
|
||||||
|
{
|
||||||
|
// Pulls the current theme brush at call time so dialogs respect light/dark/HC themes.
|
||||||
|
private static SolidColorBrush R(string key)
|
||||||
|
=> (SolidColorBrush)Application.Current.Resources[key];
|
||||||
|
|
||||||
|
private static string L(string key, string fallback)
|
||||||
|
=> Application.Current.TryFindResource(key) as string ?? fallback;
|
||||||
|
|
||||||
|
// Carries the checkbox state of the last Show() call back to ShowWithCheckbox. Dialogs are
|
||||||
|
// modal and UI-thread only, so a shared field is safe and avoids a duplicate dialog body.
|
||||||
|
private static bool _lastCheckboxChecked;
|
||||||
|
|
||||||
|
#pragma warning disable IDE0060 // image intentionally kept for API parity with MessageBox; not yet rendered
|
||||||
|
public static MessageBoxResult Show(
|
||||||
|
Window? owner,
|
||||||
|
string message,
|
||||||
|
string title = "KillerPDF",
|
||||||
|
MessageBoxButton buttons = MessageBoxButton.OK,
|
||||||
|
MessageBoxImage image = MessageBoxImage.None,
|
||||||
|
bool fadeClose = true,
|
||||||
|
string? checkboxText = null,
|
||||||
|
MessageBoxResult? defaultResult = null)
|
||||||
|
#pragma warning restore IDE0060
|
||||||
|
{
|
||||||
|
var result = MessageBoxResult.OK;
|
||||||
|
bool boxChecked = false;
|
||||||
|
|
||||||
|
var win = new Window
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
Width = 380,
|
||||||
|
SizeToContent = SizeToContent.Height
|
||||||
|
};
|
||||||
|
DialogChrome.Configure(win, owner, fade: fadeClose);
|
||||||
|
|
||||||
|
var outerBorder = new Border
|
||||||
|
{
|
||||||
|
Background = R("MenuBackgroundBrush"),
|
||||||
|
BorderBrush = UiKit.Brush("DialogFrameBrush"),
|
||||||
|
BorderThickness = Application.Current.TryFindResource("DialogFrameThickness") is Thickness dft ? dft : new Thickness(1),
|
||||||
|
Padding = Application.Current.TryFindResource("DialogFramePadding") is Thickness dfp ? dfp : new Thickness(0),
|
||||||
|
CornerRadius = UiKit.RadWindow,
|
||||||
|
Margin = Application.Current.TryFindResource("DialogHaloMargin") is Thickness hm ? hm : new Thickness(10),
|
||||||
|
Effect = UiKit.ShadowDialog()
|
||||||
|
};
|
||||||
|
|
||||||
|
var root = new StackPanel();
|
||||||
|
|
||||||
|
// Title bar
|
||||||
|
var titleBar = new Border
|
||||||
|
{
|
||||||
|
// Transparent so the dialog-wide film grain shows through the title bar too (it sits
|
||||||
|
// over the same BgModal surface, so it still reads as one continuous surface).
|
||||||
|
Background = Application.Current.TryFindResource("UseDialogCaption") is true ? UiKit.Brush("TitleBarBrush") : Brushes.Transparent,
|
||||||
|
Padding = new Thickness(16, 10, 16, 10),
|
||||||
|
CornerRadius = new CornerRadius(5, 5, 0, 0)
|
||||||
|
};
|
||||||
|
titleBar.MouseLeftButtonDown += (_, e) => { if (e.ButtonState == MouseButtonState.Pressed) win.DragMove(); };
|
||||||
|
// When the title is just "KillerPDF", render it as the main window's wordmark - "Killer"
|
||||||
|
// in the primary text color and "PDF" in the green logo accent, bold, with a soft shadow.
|
||||||
|
if (title == "KillerPDF")
|
||||||
|
{
|
||||||
|
var wm = new StackPanel { Orientation = Orientation.Horizontal };
|
||||||
|
var wmTb = new TextBlock { VerticalAlignment = VerticalAlignment.Center };
|
||||||
|
wmTb.Inlines.Add(new System.Windows.Documents.Run("Killer") { FontFamily = UiKit.WordmarkFont, FontWeight = FontWeights.Normal, FontSize = 15, Foreground = R("TextBrush") });
|
||||||
|
wmTb.Inlines.Add(new System.Windows.Documents.Run("PDF") { FontFamily = UiKit.WordmarkFontPdf, FontWeight = FontWeights.Bold, FontSize = 19.5, Foreground = R("AccentLogo") });
|
||||||
|
wm.Children.Add(wmTb);
|
||||||
|
// No DropShadowEffect on the text - it rasterizes and blurs the wordmark. Kept crisp.
|
||||||
|
titleBar.Child = wm;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
titleBar.Child = new TextBlock
|
||||||
|
{
|
||||||
|
Text = title,
|
||||||
|
Foreground = R("PrimaryBrush"),
|
||||||
|
FontWeight = FontWeights.Bold, // blue title -> bold
|
||||||
|
FontSize = 14,
|
||||||
|
FontFamily = UiKit.MonoFont
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (Application.Current.TryFindResource("UseDialogCaption") is true)
|
||||||
|
titleBar = DialogChrome.BuildTitleBar(win, owner, title, () => { result = MessageBoxResult.Cancel; win.Close(); });
|
||||||
|
titleBar.Height = Application.Current.TryFindResource("DialogTitleBarHeight") is double titleHeight ? titleHeight : double.NaN;
|
||||||
|
root.Children.Add(titleBar);
|
||||||
|
|
||||||
|
// Message
|
||||||
|
var msgBorder = new Border
|
||||||
|
{
|
||||||
|
Padding = new Thickness(20, 16, 20, 8),
|
||||||
|
Child = new TextBlock
|
||||||
|
{
|
||||||
|
Text = message,
|
||||||
|
Foreground = R("TextBrush"),
|
||||||
|
FontSize = 13,
|
||||||
|
TextWrapping = TextWrapping.Wrap
|
||||||
|
}
|
||||||
|
};
|
||||||
|
root.Children.Add(msgBorder);
|
||||||
|
|
||||||
|
// Optional checkbox (e.g. "Remember my choice"). Extra top padding sets it apart from the message.
|
||||||
|
if (checkboxText is not null)
|
||||||
|
{
|
||||||
|
var chk = UiKit.CheckBox(checkboxText);
|
||||||
|
chk.Margin = new Thickness(20, 10, 20, 4);
|
||||||
|
chk.Checked += (_, _2) => boxChecked = true;
|
||||||
|
chk.Unchecked += (_, _2) => boxChecked = false;
|
||||||
|
root.Children.Add(chk);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
var btnPanel = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build a minimal ControlTemplate so Background binds correctly and
|
||||||
|
// WPF's default blue hover chrome can't override our colors.
|
||||||
|
Button MakeBtn(string label, MessageBoxResult res, bool accent = false)
|
||||||
|
{
|
||||||
|
// Enter triggers the primary action. Normally that's the accent button; a caller can
|
||||||
|
// override which button is the default (e.g. the quit prompt makes the safe "No" the
|
||||||
|
// default), and the accent highlight follows so the Enter target is obvious.
|
||||||
|
bool isDefault = defaultResult is MessageBoxResult dr ? res == dr : accent;
|
||||||
|
// Shared themed button (UiKit.Make) so this dialog matches the print dialog et al.
|
||||||
|
var btn = UiKit.Make(label, isDefault);
|
||||||
|
btn.Margin = new Thickness(8, 0, 0, 0);
|
||||||
|
btn.IsDefault = isDefault;
|
||||||
|
btn.IsCancel = res == MessageBoxResult.Cancel; // Esc triggers Cancel where there is one
|
||||||
|
btn.Click += (_, _2) => { result = res; win.Close(); };
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (buttons)
|
||||||
|
{
|
||||||
|
case MessageBoxButton.OK:
|
||||||
|
btnPanel.Children.Add(MakeBtn(L("Str_Btn_OK", "OK"), MessageBoxResult.OK, accent: true));
|
||||||
|
break;
|
||||||
|
case MessageBoxButton.OKCancel:
|
||||||
|
btnPanel.Children.Add(MakeBtn(L("Str_Btn_OK", "OK"), MessageBoxResult.OK, accent: true));
|
||||||
|
btnPanel.Children.Add(MakeBtn(L("Str_Btn_Cancel", "Cancel"), MessageBoxResult.Cancel));
|
||||||
|
break;
|
||||||
|
case MessageBoxButton.YesNo:
|
||||||
|
btnPanel.Children.Add(MakeBtn(L("Str_Btn_Yes", "Yes"), MessageBoxResult.Yes, accent: true));
|
||||||
|
btnPanel.Children.Add(MakeBtn(L("Str_Btn_No", "No"), MessageBoxResult.No));
|
||||||
|
break;
|
||||||
|
case MessageBoxButton.YesNoCancel:
|
||||||
|
btnPanel.Children.Add(MakeBtn(L("Str_Btn_Yes", "Yes"), MessageBoxResult.Yes, accent: true));
|
||||||
|
btnPanel.Children.Add(MakeBtn(L("Str_Btn_No", "No"), MessageBoxResult.No));
|
||||||
|
btnPanel.Children.Add(MakeBtn(L("Str_Btn_Cancel", "Cancel"), MessageBoxResult.Cancel));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.Children.Add(new Border
|
||||||
|
{
|
||||||
|
Padding = new Thickness(16, 8, 16, 16),
|
||||||
|
Child = btnPanel
|
||||||
|
});
|
||||||
|
|
||||||
|
// Paint the same film-grain texture the app's panels use, behind the content, so the
|
||||||
|
// dialog reads as part of the same surface family instead of a flat box.
|
||||||
|
var contentGrid = new Grid();
|
||||||
|
var grain = (owner as MainWindow)?.GrainTexture;
|
||||||
|
if (grain is not null)
|
||||||
|
{
|
||||||
|
double grainOpacity = Application.Current.Resources["GrainOpacity"] is double go ? go : 0.05;
|
||||||
|
contentGrid.Children.Add(new Border
|
||||||
|
{
|
||||||
|
CornerRadius = new CornerRadius(6),
|
||||||
|
IsHitTestVisible = false,
|
||||||
|
Opacity = grainOpacity,
|
||||||
|
Background = new System.Windows.Media.ImageBrush(grain)
|
||||||
|
{
|
||||||
|
TileMode = System.Windows.Media.TileMode.Tile,
|
||||||
|
ViewportUnits = System.Windows.Media.BrushMappingMode.Absolute,
|
||||||
|
Viewport = new Rect(0, 0, 256, 256),
|
||||||
|
Stretch = System.Windows.Media.Stretch.None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
contentGrid.Children.Add(root);
|
||||||
|
win.Content = DialogChrome.WrapContent(owner, contentGrid);
|
||||||
|
win.ShowDialog();
|
||||||
|
_lastCheckboxChecked = boxChecked;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Like <see cref="Show"/> but with a custom set of buttons. Returns the index of the clicked
|
||||||
|
/// button, or -1 if the dialog was closed without a choice. The button at <paramref name="accentIndex"/>
|
||||||
|
/// is rendered as the primary (accent) action.
|
||||||
|
/// </summary>
|
||||||
|
public static int ShowChoices(
|
||||||
|
Window? owner,
|
||||||
|
string message,
|
||||||
|
string[] labels,
|
||||||
|
int accentIndex = 0,
|
||||||
|
string title = "KillerPDF")
|
||||||
|
{
|
||||||
|
int result = -1;
|
||||||
|
|
||||||
|
var win = new Window { Title = title, MinWidth = 380, MaxWidth = 760, SizeToContent = SizeToContent.WidthAndHeight };
|
||||||
|
DialogChrome.Configure(win, owner, fade: true);
|
||||||
|
|
||||||
|
var outerBorder = new Border
|
||||||
|
{
|
||||||
|
Background = R("MenuBackgroundBrush"),
|
||||||
|
BorderBrush = UiKit.Brush("DialogFrameBrush"),
|
||||||
|
BorderThickness = Application.Current.TryFindResource("DialogFrameThickness") is Thickness dft ? dft : new Thickness(1),
|
||||||
|
Padding = Application.Current.TryFindResource("DialogFramePadding") is Thickness dfp ? dfp : new Thickness(0),
|
||||||
|
CornerRadius = UiKit.RadWindow,
|
||||||
|
Margin = Application.Current.TryFindResource("DialogHaloMargin") is Thickness hm ? hm : new Thickness(10),
|
||||||
|
Effect = UiKit.ShadowDialog()
|
||||||
|
};
|
||||||
|
|
||||||
|
var root = new StackPanel();
|
||||||
|
|
||||||
|
var titleBar = new Border { Background = Application.Current.TryFindResource("UseDialogCaption") is true ? UiKit.Brush("TitleBarBrush") : Brushes.Transparent, Padding = new Thickness(16, 10, 16, 10) };
|
||||||
|
titleBar.MouseLeftButtonDown += (_, e) => { if (e.ButtonState == MouseButtonState.Pressed) win.DragMove(); };
|
||||||
|
if (title == "KillerPDF")
|
||||||
|
{
|
||||||
|
var wmTb = new TextBlock { VerticalAlignment = VerticalAlignment.Center };
|
||||||
|
wmTb.Inlines.Add(new System.Windows.Documents.Run("Killer") { FontFamily = UiKit.WordmarkFont, FontWeight = FontWeights.Normal, FontSize = 15, Foreground = R("TextBrush") });
|
||||||
|
wmTb.Inlines.Add(new System.Windows.Documents.Run("PDF") { FontFamily = UiKit.WordmarkFontPdf, FontWeight = FontWeights.Bold, FontSize = 19.5, Foreground = R("AccentLogo") });
|
||||||
|
titleBar.Child = wmTb;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
titleBar.Child = new TextBlock { Text = title, Foreground = R("PrimaryBrush"), FontWeight = FontWeights.Bold, FontSize = 14, FontFamily = UiKit.MonoFont };
|
||||||
|
}
|
||||||
|
if (Application.Current.TryFindResource("UseDialogCaption") is true)
|
||||||
|
titleBar = DialogChrome.BuildTitleBar(win, owner, title, () => win.Close());
|
||||||
|
titleBar.Height = Application.Current.TryFindResource("DialogTitleBarHeight") is double titleHeight ? titleHeight : double.NaN;
|
||||||
|
root.Children.Add(titleBar);
|
||||||
|
|
||||||
|
root.Children.Add(new Border
|
||||||
|
{
|
||||||
|
Padding = new Thickness(20, 16, 20, 8),
|
||||||
|
Child = new TextBlock { Text = message, Foreground = R("TextBrush"), FontSize = 13, TextWrapping = TextWrapping.Wrap, MaxWidth = 560 }
|
||||||
|
});
|
||||||
|
|
||||||
|
var btnPanel = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right };
|
||||||
|
for (int i = 0; i < labels.Length; i++)
|
||||||
|
{
|
||||||
|
int idx = i;
|
||||||
|
var btn = UiKit.Make(labels[i], accent: i == accentIndex);
|
||||||
|
btn.Padding = new Thickness(22, 8, 22, 8);
|
||||||
|
btn.MinWidth = 96;
|
||||||
|
btn.Margin = new Thickness(8, 0, 0, 0);
|
||||||
|
btn.Click += (_, _2) => { result = idx; win.Close(); };
|
||||||
|
btnPanel.Children.Add(btn);
|
||||||
|
}
|
||||||
|
root.Children.Add(new Border { Padding = new Thickness(16, 8, 16, 16), Child = btnPanel });
|
||||||
|
|
||||||
|
var contentGrid = new Grid();
|
||||||
|
var grain = (owner as MainWindow)?.GrainTexture;
|
||||||
|
if (grain is not null)
|
||||||
|
{
|
||||||
|
double grainOpacity = Application.Current.Resources["GrainOpacity"] is double go ? go : 0.05;
|
||||||
|
contentGrid.Children.Add(new Border
|
||||||
|
{
|
||||||
|
CornerRadius = new CornerRadius(6),
|
||||||
|
IsHitTestVisible = false,
|
||||||
|
Opacity = grainOpacity,
|
||||||
|
Background = new ImageBrush(grain)
|
||||||
|
{
|
||||||
|
TileMode = TileMode.Tile,
|
||||||
|
ViewportUnits = BrushMappingMode.Absolute,
|
||||||
|
Viewport = new Rect(0, 0, 256, 256),
|
||||||
|
Stretch = Stretch.None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
contentGrid.Children.Add(root);
|
||||||
|
win.Content = DialogChrome.WrapContent(owner, contentGrid);
|
||||||
|
win.ShowDialog();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Like <see cref="Show"/> but with a "don't warn again" style checkbox between the message and the
|
||||||
|
/// buttons. Returns the button result and the checkbox state.
|
||||||
|
/// </summary>
|
||||||
|
// Same dialog as Show(), plus a checkbox (e.g. "Remember my choice"). Delegates to the single
|
||||||
|
// Show() implementation so there is one KillerDialog box, not a duplicate.
|
||||||
|
public static (MessageBoxResult result, bool isChecked) ShowWithCheckbox(
|
||||||
|
Window? owner,
|
||||||
|
string message,
|
||||||
|
string checkboxText,
|
||||||
|
string title = "KillerPDF",
|
||||||
|
MessageBoxButton buttons = MessageBoxButton.OKCancel,
|
||||||
|
MessageBoxResult? defaultResult = null)
|
||||||
|
{
|
||||||
|
var result = Show(owner, message, title, buttons, checkboxText: checkboxText, defaultResult: defaultResult);
|
||||||
|
return (result, _lastCheckboxChecked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// KillerFind-style quit prompt (family standard): a short question with TWO opt-out
|
||||||
|
/// checkboxes stacked between the message and the buttons - "Close my open tabs"
|
||||||
|
/// (unchecked = session reopens next launch) and "Remember my choice" - plus
|
||||||
|
/// Cancel / Quit buttons where Quit is the accent + Enter default and Esc cancels.
|
||||||
|
/// Returns (confirmed, closeTabsChecked, rememberChecked).
|
||||||
|
///
|
||||||
|
/// A thin wrapper over <see cref="ShowTwoCheckPrompt"/> since the install prompt needed the
|
||||||
|
/// same two-checkbox shape. Passing check2Initial:false keeps this behavior identical to
|
||||||
|
/// what it was when the body lived here.
|
||||||
|
/// </summary>
|
||||||
|
public static (bool confirmed, bool closeTabs, bool remember) ShowQuitPrompt(
|
||||||
|
Window? owner,
|
||||||
|
string message,
|
||||||
|
string closeTabsText,
|
||||||
|
bool closeTabsInitial,
|
||||||
|
string rememberText,
|
||||||
|
string quitLabel,
|
||||||
|
string cancelLabel)
|
||||||
|
=> ShowTwoCheckPrompt(owner, message, closeTabsText, closeTabsInitial,
|
||||||
|
rememberText, false, quitLabel, cancelLabel);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The family two-checkbox confirm: a short question, two checkboxes stacked between the
|
||||||
|
/// message and the buttons, and Cancel / confirm buttons where confirm is the accent +
|
||||||
|
/// Enter default and Esc cancels. Used by the quit prompt and by the install prompt
|
||||||
|
/// (desktop shortcut + install for all users).
|
||||||
|
/// </summary>
|
||||||
|
public static (bool confirmed, bool check1, bool check2) ShowTwoCheckPrompt(
|
||||||
|
Window? owner,
|
||||||
|
string message,
|
||||||
|
string check1Text,
|
||||||
|
bool check1Initial,
|
||||||
|
string check2Text,
|
||||||
|
bool check2Initial,
|
||||||
|
string confirmLabel,
|
||||||
|
string cancelLabel)
|
||||||
|
{
|
||||||
|
bool confirmed = false;
|
||||||
|
bool closeTabs = check1Initial;
|
||||||
|
bool remember = check2Initial;
|
||||||
|
|
||||||
|
var win = new Window { Title = "KillerPDF", Width = 380, SizeToContent = SizeToContent.Height };
|
||||||
|
// fade:false - the app's own fade-out follows immediately on confirm; two fades
|
||||||
|
// back-to-back read as lag (same reasoning as the unsaved-changes prompt).
|
||||||
|
DialogChrome.Configure(win, owner, fade: false);
|
||||||
|
|
||||||
|
var outerBorder = new Border
|
||||||
|
{
|
||||||
|
Background = R("MenuBackgroundBrush"),
|
||||||
|
BorderBrush = UiKit.Brush("DialogFrameBrush"),
|
||||||
|
BorderThickness = Application.Current.TryFindResource("DialogFrameThickness") is Thickness dft ? dft : new Thickness(1),
|
||||||
|
Padding = Application.Current.TryFindResource("DialogFramePadding") is Thickness dfp ? dfp : new Thickness(0),
|
||||||
|
CornerRadius = UiKit.RadWindow,
|
||||||
|
Margin = Application.Current.TryFindResource("DialogHaloMargin") is Thickness hm ? hm : new Thickness(10),
|
||||||
|
Effect = UiKit.ShadowDialog()
|
||||||
|
};
|
||||||
|
|
||||||
|
var root = new StackPanel();
|
||||||
|
|
||||||
|
// Title bar: the wordmark, exactly like Show()'s "KillerPDF" branch.
|
||||||
|
var titleBar = new Border
|
||||||
|
{
|
||||||
|
Background = Application.Current.TryFindResource("UseDialogCaption") is true ? UiKit.Brush("TitleBarBrush") : Brushes.Transparent,
|
||||||
|
Padding = new Thickness(16, 10, 16, 10),
|
||||||
|
CornerRadius = new CornerRadius(5, 5, 0, 0)
|
||||||
|
};
|
||||||
|
titleBar.MouseLeftButtonDown += (_, e) => { if (e.ButtonState == MouseButtonState.Pressed) win.DragMove(); };
|
||||||
|
var wm = new StackPanel { Orientation = Orientation.Horizontal };
|
||||||
|
var wmTb = new TextBlock { VerticalAlignment = VerticalAlignment.Center };
|
||||||
|
wmTb.Inlines.Add(new System.Windows.Documents.Run("Killer") { FontFamily = UiKit.WordmarkFont, FontWeight = FontWeights.Normal, FontSize = 15, Foreground = R("TextBrush") });
|
||||||
|
wmTb.Inlines.Add(new System.Windows.Documents.Run("PDF") { FontFamily = UiKit.WordmarkFontPdf, FontWeight = FontWeights.Bold, FontSize = 18, Foreground = R("AccentLogo") });
|
||||||
|
wm.Children.Add(wmTb);
|
||||||
|
titleBar.Child = wm;
|
||||||
|
if (Application.Current.TryFindResource("UseDialogCaption") is true)
|
||||||
|
titleBar = DialogChrome.BuildTitleBar(win, owner, "KillerPDF", () => win.Close());
|
||||||
|
titleBar.Height = Application.Current.TryFindResource("DialogTitleBarHeight") is double titleHeight ? titleHeight : double.NaN;
|
||||||
|
root.Children.Add(titleBar);
|
||||||
|
|
||||||
|
root.Children.Add(new Border
|
||||||
|
{
|
||||||
|
Padding = new Thickness(20, 16, 20, 8),
|
||||||
|
Child = new TextBlock
|
||||||
|
{
|
||||||
|
Text = message,
|
||||||
|
Foreground = R("TextBrush"),
|
||||||
|
FontSize = 13,
|
||||||
|
TextWrapping = TextWrapping.Wrap
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var chk1 = UiKit.CheckBox(check1Text);
|
||||||
|
chk1.Margin = new Thickness(20, 10, 20, 0);
|
||||||
|
chk1.IsChecked = check1Initial;
|
||||||
|
chk1.Checked += (_, _2) => closeTabs = true;
|
||||||
|
chk1.Unchecked += (_, _2) => closeTabs = false;
|
||||||
|
root.Children.Add(chk1);
|
||||||
|
|
||||||
|
var chk2 = UiKit.CheckBox(check2Text);
|
||||||
|
chk2.Margin = new Thickness(20, 8, 20, 4);
|
||||||
|
chk2.IsChecked = check2Initial;
|
||||||
|
chk2.Checked += (_, _2) => remember = true;
|
||||||
|
chk2.Unchecked += (_, _2) => remember = false;
|
||||||
|
root.Children.Add(chk2);
|
||||||
|
|
||||||
|
var btnPanel = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right
|
||||||
|
};
|
||||||
|
var cancelBtn = UiKit.Make(cancelLabel, false);
|
||||||
|
cancelBtn.Margin = new Thickness(8, 0, 0, 0);
|
||||||
|
cancelBtn.IsCancel = true;
|
||||||
|
cancelBtn.Click += (_, _2) => win.Close();
|
||||||
|
var quitBtn = UiKit.Make(confirmLabel, true);
|
||||||
|
quitBtn.Margin = new Thickness(8, 0, 0, 0);
|
||||||
|
quitBtn.IsDefault = true;
|
||||||
|
quitBtn.Click += (_, _2) => { confirmed = true; win.Close(); };
|
||||||
|
btnPanel.Children.Add(cancelBtn);
|
||||||
|
btnPanel.Children.Add(quitBtn);
|
||||||
|
root.Children.Add(new Border
|
||||||
|
{
|
||||||
|
Padding = new Thickness(16, 12, 16, 16),
|
||||||
|
Child = btnPanel
|
||||||
|
});
|
||||||
|
|
||||||
|
// Film grain across the whole card, same as Show().
|
||||||
|
var contentGrid = new Grid();
|
||||||
|
var grain = (owner as MainWindow)?.GrainTexture;
|
||||||
|
if (grain is not null)
|
||||||
|
{
|
||||||
|
double grainOpacity = Application.Current.Resources["GrainOpacity"] is double go ? go : 0.05;
|
||||||
|
contentGrid.Children.Add(new Border
|
||||||
|
{
|
||||||
|
CornerRadius = new CornerRadius(6),
|
||||||
|
IsHitTestVisible = false,
|
||||||
|
Opacity = grainOpacity,
|
||||||
|
Background = new System.Windows.Media.ImageBrush(grain)
|
||||||
|
{
|
||||||
|
TileMode = System.Windows.Media.TileMode.Tile,
|
||||||
|
ViewportUnits = System.Windows.Media.BrushMappingMode.Absolute,
|
||||||
|
Viewport = new Rect(0, 0, 256, 256),
|
||||||
|
Stretch = System.Windows.Media.Stretch.None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
contentGrid.Children.Add(root);
|
||||||
|
win.Content = DialogChrome.WrapContent(owner, contentGrid);
|
||||||
|
win.ShowDialog();
|
||||||
|
return (confirmed, closeTabs, remember);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Themed "Password Required" prompt: the family dialog chrome (wordmark title bar, grain,
|
||||||
|
/// red close, Esc to cancel) around a themed PasswordBox. Returns the entered password, or
|
||||||
|
/// null if the user canceled / closed the dialog.
|
||||||
|
/// </summary>
|
||||||
|
public static string? PromptPassword(Window? owner, string filename)
|
||||||
|
{
|
||||||
|
string? result = null;
|
||||||
|
|
||||||
|
var win = new Window { Width = 380, SizeToContent = SizeToContent.Height };
|
||||||
|
DialogChrome.Configure(win, owner, fade: true);
|
||||||
|
|
||||||
|
void CloseCancel() { result = null; win.Close(); }
|
||||||
|
|
||||||
|
var body = new StackPanel();
|
||||||
|
|
||||||
|
// Message: "<file>" is password protected.
|
||||||
|
var msg = new TextBlock { Foreground = R("TextBrush"), FontSize = 13, TextWrapping = TextWrapping.Wrap };
|
||||||
|
msg.Inlines.Add(new System.Windows.Documents.Run($"“{System.IO.Path.GetFileName(filename)}” ") { FontWeight = FontWeights.SemiBold });
|
||||||
|
msg.Inlines.Add(new System.Windows.Documents.Run("is password protected."));
|
||||||
|
body.Children.Add(new Border { Padding = new Thickness(20, 4, 20, 10), Child = msg });
|
||||||
|
|
||||||
|
var pw = UiKit.PasswordField();
|
||||||
|
body.Children.Add(new Border { Padding = new Thickness(20, 0, 20, 4), Child = pw });
|
||||||
|
|
||||||
|
var openBtn = UiKit.Make("Open", accent: true);
|
||||||
|
openBtn.IsDefault = true;
|
||||||
|
openBtn.Click += (_, _2) => { result = pw.Password; win.Close(); };
|
||||||
|
var cancelBtn = UiKit.Make("Cancel", accent: false);
|
||||||
|
cancelBtn.IsCancel = true;
|
||||||
|
cancelBtn.Click += (_, _2) => CloseCancel();
|
||||||
|
body.Children.Add(new Border { Padding = new Thickness(16, 12, 16, 16), Child = UiKit.ButtonRow(openBtn, cancelBtn) });
|
||||||
|
|
||||||
|
// Enter anywhere in the field submits.
|
||||||
|
pw.KeyDown += (_, e) => { if (e.Key == Key.Enter) { result = pw.Password; win.Close(); } };
|
||||||
|
|
||||||
|
win.Content = DialogChrome.Frame(win, owner, "KillerPDF", CloseCancel, body);
|
||||||
|
win.Loaded += (_, _2) => pw.Focus();
|
||||||
|
win.ShowDialog();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace KillerPDF.Controls
|
||||||
|
{
|
||||||
|
// Row and place models for FileDialog.
|
||||||
|
public sealed class PickerPlace(string label, string path, bool pinned = false)
|
||||||
|
{
|
||||||
|
public string Label { get; } = label;
|
||||||
|
public string Path { get; } = path;
|
||||||
|
|
||||||
|
/// <summary>True for a user-pinned (removable) entry; drives are dynamic and never pinned.</summary>
|
||||||
|
public bool Pinned { get; } = pinned;
|
||||||
|
|
||||||
|
/// <summary>Real shell icon, resolved by PATH - a drive shows its true icon (USB,
|
||||||
|
/// network, optical) and a special folder its own. Cached in ShellIcons.</summary>
|
||||||
|
public System.Windows.Media.ImageSource? Icon => Services.ShellIcons.Place(Path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row in the folder pane: a subfolder or a (dimmed, non-pickable) file.
|
||||||
|
public sealed class PickerEntry(string name, string fullPath, bool isFolder, long sizeBytes, DateTime modified)
|
||||||
|
{
|
||||||
|
private static readonly string GlyphFolder = ((char)0xE8B7).ToString();
|
||||||
|
private static readonly string GlyphFile = ((char)0xE8A5).ToString();
|
||||||
|
|
||||||
|
public string Name { get; } = name;
|
||||||
|
public string FullPath { get; } = fullPath;
|
||||||
|
public bool IsFolder { get; } = isFolder;
|
||||||
|
public long SizeBytes { get; } = sizeBytes;
|
||||||
|
public DateTime Modified { get; } = modified;
|
||||||
|
|
||||||
|
public string Glyph => IsFolder ? GlyphFolder : GlyphFile;
|
||||||
|
|
||||||
|
/// <summary>Shell icon, 16px, for the list and details rows. Cached by extension, so
|
||||||
|
/// binding it per row is cheap.</summary>
|
||||||
|
public System.Windows.Media.ImageSource? Icon
|
||||||
|
=> Services.ShellIcons.Small(FullPath, IsFolder);
|
||||||
|
|
||||||
|
/// <summary>Shell icon, 32px, for the icon grid.</summary>
|
||||||
|
public System.Windows.Media.ImageSource? IconLarge
|
||||||
|
=> Services.ShellIcons.Large(FullPath, IsFolder);
|
||||||
|
|
||||||
|
public string SizeLabel => IsFolder ? string.Empty : FormatSize(SizeBytes);
|
||||||
|
public string ModifiedLabel => Modified == DateTime.MinValue ? string.Empty : Modified.ToString("yyyy-MM-dd HH:mm");
|
||||||
|
|
||||||
|
private static string FormatSize(long b)
|
||||||
|
{
|
||||||
|
if (b < 1024) return b + " B";
|
||||||
|
double kb = b / 1024.0;
|
||||||
|
if (kb < 1024) return kb.ToString("0") + " KB";
|
||||||
|
double mb = kb / 1024.0;
|
||||||
|
if (mb < 1024) return mb.ToString("0.0") + " MB";
|
||||||
|
return (mb / 1024.0).ToString("0.00") + " GB";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,804 @@
|
|||||||
|
<!-- ============================================================
|
||||||
|
File picker styles, ported verbatim from Killendar Controls.xaml (the family
|
||||||
|
reference). Emitted in the source file order, which already satisfies every
|
||||||
|
StaticResource forward reference.
|
||||||
|
|
||||||
|
The key list is derived from BOTH the picker XAML and its code-behind: ApplyView()
|
||||||
|
resolves PanelListCols / PanelIconGrid / IconTemplate / DetailsTemplate through
|
||||||
|
FindResource(), so a scan of XAML references alone misses them and the dialog dies
|
||||||
|
at runtime the first time the view mode is applied.
|
||||||
|
|
||||||
|
Merged by Controls/FileDialog.xaml, NOT App.xaml: LocaleManager owns application
|
||||||
|
merged-dictionary slot [2] and removes it outright for English.
|
||||||
|
============================================================ -->
|
||||||
|
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:local="clr-namespace:KillerPDF.Controls"
|
||||||
|
xmlns:kui="clr-namespace:KillerPDF.Controls">
|
||||||
|
|
||||||
|
<Style x:Key="OutlineButton" TargetType="Button">
|
||||||
|
<!-- Transparent at rest, NOT RowSelectedBrush. Filled with RowSelectedBrush while its text
|
||||||
|
was OutlineBtnBrush, both resolve to the accent on several palettes - so the confirm
|
||||||
|
button was a solid block of color with its caption invisible inside it. -->
|
||||||
|
<Setter Property="Background" Value="{DynamicResource OutlineFaceBrush}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource OutlineTextBrush}"/>
|
||||||
|
<Setter Property="FontSize" Value="13"/>
|
||||||
|
<Setter Property="Padding" Value="14,5"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource OutlineRestBrush}"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<!-- WPF's dotted focus rectangle draws over the caption; the template shows focus itself. -->
|
||||||
|
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<!-- Bevels are SIBLINGS of the border so they land on the button's outer edge,
|
||||||
|
not inside its 1px edge and 14,5 padding. Zero-thickness off 98SE. -->
|
||||||
|
<Grid>
|
||||||
|
<Border x:Name="border" Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
CornerRadius="{DynamicResource ControlCornerRadius}" Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<Border x:Name="bevelLight" IsHitTestVisible="False" BorderBrush="{DynamicResource BevelLightBrush}" BorderThickness="{DynamicResource ButtonBevelLightThickness}"/>
|
||||||
|
<Border x:Name="bevelDark" IsHitTestVisible="False" BorderBrush="{DynamicResource BevelDarkBrush}" BorderThickness="{DynamicResource ButtonBevelDarkThickness}"/>
|
||||||
|
</Grid>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<!-- Order matters: IsPressed must come after IsMouseOver to win. -->
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="border" Property="Background" Value="{DynamicResource OutlineHoverBrush}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource OutlineHoverTextBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsPressed" Value="True">
|
||||||
|
<Setter TargetName="border" Property="Background" Value="{DynamicResource OutlinePressedBrush}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource OutlineTextBrush}"/>
|
||||||
|
<!-- Bevel inverts on press: sunken, the classic behavior. -->
|
||||||
|
<Setter TargetName="bevelLight" Property="BorderBrush" Value="{DynamicResource BevelDarkBrush}"/>
|
||||||
|
<Setter TargetName="bevelDark" Property="BorderBrush" Value="{DynamicResource BevelLightBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsEnabled" Value="False">
|
||||||
|
<Setter Property="Opacity" Value="0.5"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="SurfaceButton" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource PaneBrush}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
|
||||||
|
<Setter Property="FontSize" Value="13"/>
|
||||||
|
<Setter Property="Padding" Value="12,6"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<!-- ButtonEdgeBrush: a beveled theme makes it transparent so the bevel is the edge and
|
||||||
|
the flat outline does not draw a second line beside it. -->
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ButtonEdgeBrush}"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Grid>
|
||||||
|
<Border x:Name="border" Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
CornerRadius="{DynamicResource ControlCornerRadius}" Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<Border x:Name="bevelLight" IsHitTestVisible="False" BorderBrush="{DynamicResource BevelLightBrush}" BorderThickness="{DynamicResource ButtonBevelLightThickness}"/>
|
||||||
|
<Border x:Name="bevelDark" IsHitTestVisible="False" BorderBrush="{DynamicResource BevelDarkBrush}" BorderThickness="{DynamicResource ButtonBevelDarkThickness}"/>
|
||||||
|
</Grid>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<!-- Order matters: IsPressed after IsMouseOver so it wins. -->
|
||||||
|
<!-- A real fill change, not an opacity fade. Opacity 0.75 on a PaneBrush
|
||||||
|
button over the dark panel is a ~9-step move - invisible in practice,
|
||||||
|
so Cancel read as dead - no hover at all. (2026-07-30) -->
|
||||||
|
<!-- Fill only, no accent border: the accent outline is the CONFIRM
|
||||||
|
button's identity (OutlineButton), and a neutral button borrowing it
|
||||||
|
on hover reads as a second confirm. (2026-07-30) -->
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="border" Property="Background" Value="{DynamicResource RowHoverBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsPressed" Value="True">
|
||||||
|
<Setter TargetName="border" Property="Background" Value="{DynamicResource SurfaceBrush}"/>
|
||||||
|
<Setter TargetName="bevelLight" Property="BorderBrush" Value="{DynamicResource BevelDarkBrush}"/>
|
||||||
|
<Setter TargetName="bevelDark" Property="BorderBrush" Value="{DynamicResource BevelLightBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsEnabled" Value="False">
|
||||||
|
<Setter Property="Opacity" Value="0.4"/>
|
||||||
|
<Setter Property="Cursor" Value="Arrow"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="DarkTextBox" TargetType="TextBox">
|
||||||
|
<!-- SurfaceBrush (#333333 on Dark), NOT BackgroundBrush. The appointment sidebar paints
|
||||||
|
nothing of its own and sits straight on the window's BackgroundBrush (#1c1c1c), so a
|
||||||
|
BackgroundBrush field was the exact same color as the panel behind it - the only thing
|
||||||
|
separating a text box from empty space was its 1px border. KillerNotes has always used
|
||||||
|
SurfaceBrush here; this is that. (2026-07-30)
|
||||||
|
It reads correctly on the other surfaces too: the dialog cards are PaneBrush (#3a3a3a),
|
||||||
|
against which a SurfaceBrush field is a step darker and reads as recessed. -->
|
||||||
|
<Setter Property="Background" Value="{DynamicResource TextFieldBrush}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource InputBorderBrush}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<Setter Property="Padding" Value="8,6"/>
|
||||||
|
<Setter Property="FontSize" Value="14"/>
|
||||||
|
<Setter Property="CaretBrush" Value="{DynamicResource TextBrush}"/>
|
||||||
|
<Setter Property="SelectionBrush" Value="{DynamicResource PrimaryBrush}"/>
|
||||||
|
<Setter Property="SelectionOpacity" Value="0.3"/>
|
||||||
|
<!-- Replaces WPF's built-in Cut/Copy/Paste menu, which is built from the framework theme
|
||||||
|
and ignores this app's ContextMenu/MenuItem styles. DynamicResource, not Static: the
|
||||||
|
menu is defined further down this file. -->
|
||||||
|
<Setter Property="ContextMenu" Value="{DynamicResource TextInputContextMenu}"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="TextBox">
|
||||||
|
<Border x:Name="border" Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
CornerRadius="{DynamicResource ControlCornerRadius}">
|
||||||
|
<!-- VerticalAlignment must follow VerticalContentAlignment. Without it the
|
||||||
|
content host fills the box top-down, so in a fixed-height row the text
|
||||||
|
line is taller than the space left by Padding and scrolls out of sight,
|
||||||
|
leaving what looks like an empty box. -->
|
||||||
|
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}"
|
||||||
|
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="border" Property="BorderBrush" Value="{DynamicResource InputHoverBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsKeyboardFocused" Value="True">
|
||||||
|
<Setter TargetName="border" Property="BorderBrush" Value="{DynamicResource PrimaryBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<ContextMenu x:Key="TextInputContextMenu" x:Shared="False">
|
||||||
|
<MenuItem Command="ApplicationCommands.Cut" Header="{DynamicResource Str_Ctx_Cut}" InputGestureText="Ctrl+X">
|
||||||
|
<MenuItem.Icon><TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="12"/></MenuItem.Icon>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Command="ApplicationCommands.Copy" Header="{DynamicResource Str_Ctx_Copy}" InputGestureText="Ctrl+C">
|
||||||
|
<MenuItem.Icon><TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="12"/></MenuItem.Icon>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Command="ApplicationCommands.Paste" Header="{DynamicResource Str_Ctx_Paste}" InputGestureText="Ctrl+V">
|
||||||
|
<MenuItem.Icon><TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="12"/></MenuItem.Icon>
|
||||||
|
</MenuItem>
|
||||||
|
<Separator/>
|
||||||
|
<MenuItem Command="ApplicationCommands.SelectAll" Header="{DynamicResource Str_Ctx_SelectAll}" InputGestureText="Ctrl+A">
|
||||||
|
<MenuItem.Icon><TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="12"/></MenuItem.Icon>
|
||||||
|
</MenuItem>
|
||||||
|
</ContextMenu>
|
||||||
|
|
||||||
|
<!-- The places / bookmarks rail. Same template as PickerRow but tight: the rail is a
|
||||||
|
navigation list beside a folder tree, so its rows should match the tree's line height and
|
||||||
|
let you see more at once. PickerRow's 8,5 padding plus 2,1 margin made each place ~28px
|
||||||
|
tall for a 16px icon - over half the row was air. Kept separate from PickerRow so the
|
||||||
|
file list, where rows carry three columns of detail, is unaffected. -->
|
||||||
|
<Style x:Key="PickerPlaceRow" TargetType="ListBoxItem">
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ListBoxItem">
|
||||||
|
<Border x:Name="bg" Background="Transparent" CornerRadius="{DynamicResource SmallCornerRadius}" Padding="6,1" Margin="2,0">
|
||||||
|
<ContentPresenter/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="bg" Property="Background" Value="{DynamicResource RowHoverBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsSelected" Value="True">
|
||||||
|
<Setter TargetName="bg" Property="Background" Value="{DynamicResource SelectionBg}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource SelectionFg}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="PickerRow" TargetType="ListBoxItem">
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ListBoxItem">
|
||||||
|
<Border x:Name="bg" Background="Transparent" CornerRadius="{DynamicResource ControlCornerRadius}" Padding="8,5" Margin="2,1">
|
||||||
|
<ContentPresenter/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="bg" Property="Background" Value="{DynamicResource RowHoverBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
<!-- SelectionBg/SelectionFg, the family pair - never the raw accent.
|
||||||
|
Same treatment as a selected menu item or tab. (2026-07-30) -->
|
||||||
|
<Trigger Property="IsSelected" Value="True">
|
||||||
|
<Setter TargetName="bg" Property="Background" Value="{DynamicResource SelectionBg}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource SelectionFg}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="PickerRowContent" TargetType="Panel">
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=ListBoxItem}}" Value="True">
|
||||||
|
<Setter Property="Effect" Value="{DynamicResource TextStroke}"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="PickerName" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontFamily" Value="Consolas"/>
|
||||||
|
<Setter Property="FontSize" Value="12"/>
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||||
|
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsFolder}" Value="False">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource MutedTextBrush}"/>
|
||||||
|
</DataTrigger>
|
||||||
|
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=ListBoxItem}}" Value="True">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource SelectionFg}"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="PickerMeta" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontFamily" Value="Consolas"/>
|
||||||
|
<Setter Property="FontSize" Value="11"/>
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource DimTextBrush}"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=ListBoxItem}}" Value="True">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource SelectionFg}"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="PickerViewBtn" TargetType="Button">
|
||||||
|
<Setter Property="Width" Value="26"/>
|
||||||
|
<Setter Property="Height" Value="22"/>
|
||||||
|
<Setter Property="FontFamily" Value="Segoe MDL2 Assets"/>
|
||||||
|
<Setter Property="FontSize" Value="12"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource DimTextBrush}"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<!-- Bevels as siblings so the raised edge is the button's own edge. This covers
|
||||||
|
the view-mode buttons AND the up-a-folder button, which share this style. -->
|
||||||
|
<Grid>
|
||||||
|
<Border x:Name="bg" Background="Transparent" CornerRadius="{DynamicResource SmallCornerRadius}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<Border x:Name="bevelLight" IsHitTestVisible="False" BorderBrush="{DynamicResource BevelLightBrush}" BorderThickness="{DynamicResource ButtonBevelLightThickness}"/>
|
||||||
|
<Border x:Name="bevelDark" IsHitTestVisible="False" BorderBrush="{DynamicResource BevelDarkBrush}" BorderThickness="{DynamicResource ButtonBevelDarkThickness}"/>
|
||||||
|
</Grid>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="bg" Property="Background" Value="{DynamicResource RowHoverBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
<!-- SelectionBg/SelectionFg, a matched pair. It was RowSelectedBrush with a
|
||||||
|
PrimaryBrush glyph - on the palettes where those are both the accent,
|
||||||
|
the active view button was an accent square with an accent icon on it,
|
||||||
|
so the selected view was the one you could not see. The bevel also
|
||||||
|
inverts, so the active button reads as pressed in. -->
|
||||||
|
<Trigger Property="Tag" Value="on">
|
||||||
|
<Setter TargetName="bg" Property="Background" Value="{DynamicResource SelectionBg}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource SelectionFg}"/>
|
||||||
|
<Setter TargetName="bevelLight" Property="BorderBrush" Value="{DynamicResource BevelDarkBrush}"/>
|
||||||
|
<Setter TargetName="bevelDark" Property="BorderBrush" Value="{DynamicResource BevelLightBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- The address-bar dropdown is the same classic arrow face used by ComboBox fields.
|
||||||
|
Keeping it here means every Open, Save and image picker receives one implementation. -->
|
||||||
|
<Style x:Key="PickerComboArrowBtn" TargetType="Button">
|
||||||
|
<Setter Property="Width" Value="{DynamicResource ComboButtonSize}"/>
|
||||||
|
<Setter Property="Height" Value="{DynamicResource ComboButtonHeight}"/>
|
||||||
|
<Setter Property="FontFamily" Value="{DynamicResource ComboChevFont}"/>
|
||||||
|
<Setter Property="FontSize" Value="8"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Focusable" Value="False"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Grid>
|
||||||
|
<Border x:Name="face" Background="{DynamicResource ComboButtonBrush}"/>
|
||||||
|
<Border x:Name="bevelLight" IsHitTestVisible="False"
|
||||||
|
BorderBrush="{DynamicResource BevelLightBrush}"
|
||||||
|
BorderThickness="{DynamicResource ButtonBevelLightThickness}"/>
|
||||||
|
<Border x:Name="bevelDark" IsHitTestVisible="False"
|
||||||
|
BorderBrush="{DynamicResource BevelDarkBrush}"
|
||||||
|
BorderThickness="{DynamicResource ButtonBevelDarkThickness}"/>
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="face" Property="Background" Value="{DynamicResource ComboButtonHoverBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsPressed" Value="True">
|
||||||
|
<Setter TargetName="bevelLight" Property="BorderBrush" Value="{DynamicResource BevelDarkBrush}"/>
|
||||||
|
<Setter TargetName="bevelDark" Property="BorderBrush" Value="{DynamicResource BevelLightBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="PickerColBtn" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource DimTextBrush}"/>
|
||||||
|
<Setter Property="FontFamily" Value="Consolas"/>
|
||||||
|
<Setter Property="FontSize" Value="10"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Left"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border Background="Transparent" Padding="0,3">
|
||||||
|
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource PrimaryBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<DataTemplate x:Key="RowTemplate">
|
||||||
|
<StackPanel Orientation="Horizontal" Style="{StaticResource PickerRowContent}" Width="210">
|
||||||
|
<Image Source="{Binding Icon}" Width="16" Height="16" VerticalAlignment="Center"
|
||||||
|
Margin="0,0,8,0" SnapsToDevicePixels="True"
|
||||||
|
RenderOptions.BitmapScalingMode="NearestNeighbor"/>
|
||||||
|
<TextBlock Text="{Binding Name}" Style="{StaticResource PickerName}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
|
||||||
|
<DataTemplate x:Key="IconTemplate">
|
||||||
|
<StackPanel Style="{StaticResource PickerRowContent}" Width="84">
|
||||||
|
<Image Source="{Binding IconLarge}" Width="32" Height="32"
|
||||||
|
HorizontalAlignment="Center" Margin="0,2,0,4" SnapsToDevicePixels="True"/>
|
||||||
|
<TextBlock Text="{Binding Name}" Style="{StaticResource PickerName}"
|
||||||
|
TextWrapping="Wrap" TextAlignment="Center" MaxHeight="30"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
|
||||||
|
<DataTemplate x:Key="DetailsTemplate">
|
||||||
|
<Grid Style="{StaticResource PickerRowContent}">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="80"/>
|
||||||
|
<ColumnDefinition Width="150"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal">
|
||||||
|
<Image Source="{Binding Icon}" Width="16" Height="16" VerticalAlignment="Center"
|
||||||
|
Margin="0,0,8,0" SnapsToDevicePixels="True"
|
||||||
|
RenderOptions.BitmapScalingMode="NearestNeighbor"/>
|
||||||
|
<TextBlock Text="{Binding Name}" Style="{StaticResource PickerName}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding SizeLabel}" Style="{StaticResource PickerMeta}" TextAlignment="Right" Margin="0,0,10,0"/>
|
||||||
|
<TextBlock Grid.Column="2" Text="{Binding ModifiedLabel}" Style="{StaticResource PickerMeta}" Margin="10,0,0,0"/>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
|
||||||
|
<ItemsPanelTemplate x:Key="PanelStack"><VirtualizingStackPanel/></ItemsPanelTemplate>
|
||||||
|
|
||||||
|
<ItemsPanelTemplate x:Key="PanelListCols">
|
||||||
|
<WrapPanel Orientation="Vertical" ItemHeight="26"/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
|
||||||
|
<ItemsPanelTemplate x:Key="PanelIconGrid">
|
||||||
|
<WrapPanel Orientation="Horizontal" ItemWidth="96" ItemHeight="76"/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
|
||||||
|
<kui:LastChildConverter x:Key="IsLastChild"/>
|
||||||
|
|
||||||
|
<Style x:Key="TreeExpander" TargetType="ToggleButton">
|
||||||
|
<Setter Property="Focusable" Value="False"/>
|
||||||
|
<Setter Property="Width" Value="16"/>
|
||||||
|
<Setter Property="Height" Value="16"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ToggleButton">
|
||||||
|
<Border Background="Transparent" Width="16" Height="16">
|
||||||
|
<Canvas Width="16" Height="16">
|
||||||
|
<Path x:Name="arrowFill" Data="M 7,3.5 L 13,7.5 L 7,11.5"
|
||||||
|
Fill="{DynamicResource BackgroundBrush}" SnapsToDevicePixels="False"/>
|
||||||
|
<Path x:Name="arrow" Data="M 8.5,3.5 L 13,7.5 L 8.5,11.5"
|
||||||
|
StrokeThickness="1.4" StrokeStartLineCap="Round" StrokeEndLineCap="Round"
|
||||||
|
Stroke="{DynamicResource DimTextBrush}" SnapsToDevicePixels="False"/>
|
||||||
|
</Canvas>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsChecked" Value="True">
|
||||||
|
<!-- Points down, vertex landing on x=8 - the line's center. Arm length
|
||||||
|
and angle match the collapsed chevron, worked out by hand: a
|
||||||
|
RenderTransform cannot be reached by TargetName (MC4111). -->
|
||||||
|
<Setter TargetName="arrow" Property="Data" Value="M 4,5.25 L 8,9.75 L 12,5.25"/>
|
||||||
|
<Setter TargetName="arrowFill" Property="Data" Value="M 4,5.25 L 8,9.75 L 12,5.25"/>
|
||||||
|
<Setter TargetName="arrow" Property="Stroke" Value="{DynamicResource PrimaryBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="arrow" Property="Stroke" Value="{DynamicResource PrimaryBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="TreeName" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontFamily" Value="Consolas"/>
|
||||||
|
<Setter Property="FontSize" Value="11"/>
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||||
|
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=TreeViewItem}}" Value="True">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource SelectionFg}"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="FolderTreeItem" TargetType="TreeViewItem">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
|
||||||
|
<Setter Property="Padding" Value="2,1"/>
|
||||||
|
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Left"/>
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="TreeViewItem">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<!-- 16px gutter carries the expander AND the connecting lines, so
|
||||||
|
the lines land dead center on the triangle at every depth. -->
|
||||||
|
<ColumnDefinition Width="16"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Connecting lines. The vertical runs the full height of the node and
|
||||||
|
its children; the horizontal is the stub out to this node's icon.
|
||||||
|
For the last child the vertical is cut to an elbow (see trigger). -->
|
||||||
|
<Rectangle x:Name="VerLine" Grid.RowSpan="2" Width="1"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Stretch"
|
||||||
|
Fill="{DynamicResource TreeLineBrush}" SnapsToDevicePixels="True"/>
|
||||||
|
<!-- Only drawn for a node with no expander - where there IS one, the
|
||||||
|
chevron is the connector. -->
|
||||||
|
<Rectangle x:Name="HorLine" Height="1" Margin="8,0,0,0"
|
||||||
|
VerticalAlignment="Center" HorizontalAlignment="Stretch"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
Fill="{DynamicResource TreeLineBrush}" SnapsToDevicePixels="True"/>
|
||||||
|
|
||||||
|
<ToggleButton x:Name="Expander" Style="{StaticResource TreeExpander}"
|
||||||
|
ClickMode="Press" VerticalAlignment="Center"
|
||||||
|
IsChecked="{Binding IsExpanded, RelativeSource={RelativeSource TemplatedParent}}"/>
|
||||||
|
|
||||||
|
<Border x:Name="Bd" Grid.Column="1" CornerRadius="{DynamicResource SmallCornerRadius}" Margin="2,0,0,0"
|
||||||
|
Background="Transparent" Padding="{TemplateBinding Padding}"
|
||||||
|
SnapsToDevicePixels="True">
|
||||||
|
<ContentPresenter x:Name="PART_Header" ContentSource="Header"
|
||||||
|
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<ItemsPresenter x:Name="ItemsHost" Grid.Row="1" Grid.Column="1"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsExpanded" Value="False">
|
||||||
|
<Setter TargetName="ItemsHost" Property="Visibility" Value="Collapsed"/>
|
||||||
|
</Trigger>
|
||||||
|
<!-- No children means no chevron, so the elbow stub takes over as the
|
||||||
|
connector. The vertical stays either way: a leaf still belongs to
|
||||||
|
the run of siblings drawn down the gutter. -->
|
||||||
|
<Trigger Property="HasItems" Value="False">
|
||||||
|
<Setter TargetName="Expander" Property="Visibility" Value="Hidden"/>
|
||||||
|
<Setter TargetName="HorLine" Property="Visibility" Value="Visible"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsMouseOver" SourceName="Bd" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource RowHoverBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
<!-- SelectionBg, the family pair - not KillerShell's RowSelectedBrush,
|
||||||
|
because in THIS dialog the tree node and the places row mean the same
|
||||||
|
thing and must light up the same way. -->
|
||||||
|
<Trigger Property="IsSelected" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SelectionBg}"/>
|
||||||
|
</Trigger>
|
||||||
|
<!-- Last sibling: cut the vertical to an elbow. Bound to the container
|
||||||
|
itself so a recycled container re-evaluates (FolderTree.cs). -->
|
||||||
|
<DataTrigger Value="True"
|
||||||
|
Binding="{Binding RelativeSource={RelativeSource Self}, Converter={StaticResource IsLastChild}}">
|
||||||
|
<Setter TargetName="VerLine" Property="VerticalAlignment" Value="Top"/>
|
||||||
|
<Setter TargetName="VerLine" Property="Height" Value="11"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="BareScrollViewer" TargetType="ScrollViewer">
|
||||||
|
<Setter Property="Focusable" Value="False"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ScrollViewer">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<ScrollContentPresenter Grid.Row="0" Grid.Column="0"
|
||||||
|
Margin="{TemplateBinding Padding}"
|
||||||
|
CanContentScroll="{TemplateBinding CanContentScroll}"
|
||||||
|
CanHorizontallyScroll="False"
|
||||||
|
CanVerticallyScroll="False"/>
|
||||||
|
|
||||||
|
<!-- The PART_ names are what ScrollViewer wires its own scrolling to, so
|
||||||
|
Value binds one-way and the control drives it from there. -->
|
||||||
|
<ScrollBar x:Name="PART_VerticalScrollBar" Grid.Row="0" Grid.Column="1"
|
||||||
|
Orientation="Vertical" Cursor="Arrow"
|
||||||
|
Minimum="0" Maximum="{TemplateBinding ScrollableHeight}"
|
||||||
|
ViewportSize="{TemplateBinding ViewportHeight}"
|
||||||
|
Value="{Binding VerticalOffset, Mode=OneWay, RelativeSource={RelativeSource TemplatedParent}}"
|
||||||
|
Visibility="{TemplateBinding ComputedVerticalScrollBarVisibility}"/>
|
||||||
|
|
||||||
|
<ScrollBar x:Name="PART_HorizontalScrollBar" Grid.Row="1" Grid.Column="0"
|
||||||
|
Orientation="Horizontal" Cursor="Arrow"
|
||||||
|
Minimum="0" Maximum="{TemplateBinding ScrollableWidth}"
|
||||||
|
ViewportSize="{TemplateBinding ViewportWidth}"
|
||||||
|
Value="{Binding HorizontalOffset, Mode=OneWay, RelativeSource={RelativeSource TemplatedParent}}"
|
||||||
|
Visibility="{TemplateBinding ComputedHorizontalScrollBarVisibility}"/>
|
||||||
|
|
||||||
|
<!-- Row 1 / Column 1 deliberately left empty: that is the corner. -->
|
||||||
|
</Grid>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="FolderTreeView" TargetType="TreeView">
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Padding" Value="0"/>
|
||||||
|
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
|
||||||
|
<Setter Property="ItemContainerStyle" Value="{StaticResource FolderTreeItem}"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="TreeView">
|
||||||
|
<Border Background="{TemplateBinding Background}" BorderThickness="0">
|
||||||
|
<ScrollViewer Style="{StaticResource BareScrollViewer}"
|
||||||
|
Padding="{TemplateBinding Padding}"
|
||||||
|
CanContentScroll="False"
|
||||||
|
HorizontalScrollBarVisibility="Auto"
|
||||||
|
VerticalScrollBarVisibility="Auto">
|
||||||
|
<ItemsPresenter/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- Shared ComboBox chrome used by both the main-window zoom field and every file picker. -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<!-- Toggle button used inside the ComboBox template -->
|
||||||
|
<Style x:Key="DarkComboToggle" TargetType="ToggleButton">
|
||||||
|
<Setter Property="OverridesDefaultStyle" Value="True"/>
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Focusable" Value="False"/>
|
||||||
|
<Setter Property="ClickMode" Value="Press"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ToggleButton">
|
||||||
|
<Border Background="{TemplateBinding Background}" BorderThickness="0"/>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ComboBoxItem inside the dark popup -->
|
||||||
|
<Style x:Key="DarkComboItem" TargetType="ComboBoxItem">
|
||||||
|
<Setter Property="OverridesDefaultStyle" Value="True"/>
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
|
||||||
|
<Setter Property="Padding" Value="{DynamicResource MenuItemPadding}"/>
|
||||||
|
<Setter Property="FontFamily" Value="{DynamicResource MenuFontFamily}"/>
|
||||||
|
<Setter Property="FontSize" Value="{DynamicResource MenuFontSize}"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ComboBoxItem">
|
||||||
|
<Border x:Name="ItemBd"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsHighlighted" Value="True">
|
||||||
|
<Setter TargetName="ItemBd" Property="Background" Value="{DynamicResource RowHoverBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsSelected" Value="True">
|
||||||
|
<Setter TargetName="ItemBd" Property="Background" Value="{DynamicResource SelectionBg}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource SelectionFg}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- The ComboBox itself -->
|
||||||
|
<Style x:Key="DarkComboBox" TargetType="ComboBox">
|
||||||
|
<Setter Property="OverridesDefaultStyle" Value="True"/>
|
||||||
|
<Setter Property="Background" Value="{DynamicResource ComboFieldBrush}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource MenuBorderBrush}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<Setter Property="FontFamily" Value="{DynamicResource MenuFontFamily}"/>
|
||||||
|
<Setter Property="FontSize" Value="{DynamicResource MenuFontSize}"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="ItemContainerStyle" Value="{StaticResource DarkComboItem}"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ComboBox">
|
||||||
|
<Grid>
|
||||||
|
<!-- Clickable area / border -->
|
||||||
|
<Border x:Name="Bd"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
CornerRadius="{DynamicResource SmallCornerRadius}">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto" MinWidth="{DynamicResource ComboButtonMinWidth}"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<!-- Selected item text (non-editable mode) -->
|
||||||
|
<ContentPresenter x:Name="ContentSite"
|
||||||
|
Grid.Column="0"
|
||||||
|
Content="{TemplateBinding SelectionBoxItem}"
|
||||||
|
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
|
||||||
|
Margin="7,0,0,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
IsHitTestVisible="False"/>
|
||||||
|
<!-- Editable text box (shown when IsEditable=True) -->
|
||||||
|
<TextBox x:Name="PART_EditableTextBox"
|
||||||
|
Grid.Column="0"
|
||||||
|
Margin="5,0,0,0"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
Foreground="{DynamicResource TextBrush}"
|
||||||
|
FontFamily="{DynamicResource MenuFontFamily}"
|
||||||
|
FontSize="{DynamicResource MenuFontSize}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
IsReadOnly="{TemplateBinding IsReadOnly}"
|
||||||
|
Visibility="Hidden"
|
||||||
|
Focusable="True"
|
||||||
|
SelectionBrush="{DynamicResource RowSelectedBrush}"
|
||||||
|
SelectionTextBrush="{DynamicResource PrimaryBrush}"
|
||||||
|
CaretBrush="{DynamicResource PrimaryBrush}"/>
|
||||||
|
<!-- The arrow face stretches to the field's INNER height. A fixed
|
||||||
|
26px face clipped inside the 22px toolbar zoom box, cutting its
|
||||||
|
bevel in half while looking correct in taller dialog fields. -->
|
||||||
|
<Grid Grid.Column="1" Width="{DynamicResource ComboButtonSize}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Stretch"
|
||||||
|
IsHitTestVisible="False">
|
||||||
|
<Border x:Name="ComboChevFace" Background="{DynamicResource ComboButtonBrush}"/>
|
||||||
|
<Border x:Name="ComboChevLight" BorderBrush="{DynamicResource BevelLightBrush}"
|
||||||
|
BorderThickness="{DynamicResource ButtonBevelLightThickness}"/>
|
||||||
|
<Border x:Name="ComboChevDark" BorderBrush="{DynamicResource BevelDarkBrush}"
|
||||||
|
BorderThickness="{DynamicResource ButtonBevelDarkThickness}"/>
|
||||||
|
<TextBlock Text="{DynamicResource ComboChevGlyph}"
|
||||||
|
FontFamily="{DynamicResource ComboChevFont}" FontSize="8"
|
||||||
|
Foreground="{DynamicResource TextBrush}"
|
||||||
|
VerticalAlignment="Center" HorizontalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
<!-- Invisible toggle over the whole control -->
|
||||||
|
<ToggleButton Grid.Column="0" Grid.ColumnSpan="2"
|
||||||
|
Style="{StaticResource DarkComboToggle}"
|
||||||
|
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay,
|
||||||
|
RelativeSource={RelativeSource TemplatedParent}}"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<!-- A classic edit/combo field is a two-stage SUNKEN frame: gray/white
|
||||||
|
outside, black/control-face inside. Modern themes make these shared
|
||||||
|
pane-bevel resources transparent and zero-width. -->
|
||||||
|
<Border IsHitTestVisible="False" BorderBrush="{DynamicResource PaneBevelDarkBrush}"
|
||||||
|
BorderThickness="{DynamicResource PaneBevelLightThickness}"/>
|
||||||
|
<Border IsHitTestVisible="False" BorderBrush="{DynamicResource PaneBevelLightBrush}"
|
||||||
|
BorderThickness="{DynamicResource PaneBevelDarkThickness}"/>
|
||||||
|
<Border IsHitTestVisible="False" Margin="{DynamicResource PaneBevelInnerMargin}"
|
||||||
|
BorderBrush="{DynamicResource PaneBevelDark2Brush}"
|
||||||
|
BorderThickness="{DynamicResource PaneBevel2LightThickness}"/>
|
||||||
|
<Border IsHitTestVisible="False" Margin="{DynamicResource PaneBevelInnerMargin}"
|
||||||
|
BorderBrush="{DynamicResource PaneBevelLight2Brush}"
|
||||||
|
BorderThickness="{DynamicResource PaneBevel2DarkThickness}"/>
|
||||||
|
<!-- Dropdown popup -->
|
||||||
|
<Popup x:Name="PART_Popup"
|
||||||
|
AllowsTransparency="True"
|
||||||
|
IsOpen="{TemplateBinding IsDropDownOpen}"
|
||||||
|
Focusable="False"
|
||||||
|
PopupAnimation="Fade"
|
||||||
|
Placement="Bottom">
|
||||||
|
<Border Background="{DynamicResource ComboPopupBrush}"
|
||||||
|
BorderBrush="{DynamicResource CardBorderBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="{DynamicResource SmallCornerRadius}"
|
||||||
|
Padding="2"
|
||||||
|
Margin="0,2,6,6"
|
||||||
|
MinWidth="{Binding ActualWidth,
|
||||||
|
RelativeSource={RelativeSource TemplatedParent}}"
|
||||||
|
MaxHeight="{TemplateBinding MaxDropDownHeight}">
|
||||||
|
<Border.Effect>
|
||||||
|
<DropShadowEffect Color="Black" BlurRadius="12" ShadowDepth="2" Direction="270"
|
||||||
|
Opacity="{DynamicResource FlyoutShadowOpacity}"/>
|
||||||
|
</Border.Effect>
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||||
|
HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<ItemsPresenter/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
</Popup>
|
||||||
|
</Grid>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsEditable" Value="True">
|
||||||
|
<Setter TargetName="ContentSite" Property="Visibility" Value="Hidden"/>
|
||||||
|
<Setter TargetName="PART_EditableTextBox" Property="Visibility" Value="Visible"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ComboFieldHoverBrush}"/>
|
||||||
|
<Setter TargetName="ComboChevFace" Property="Background" Value="{DynamicResource ComboButtonHoverBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsDropDownOpen" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ComboFieldHoverBrush}"/>
|
||||||
|
<Setter TargetName="ComboChevFace" Property="Background" Value="{DynamicResource ComboButtonHoverBrush}"/>
|
||||||
|
<Setter TargetName="ComboChevLight" Property="BorderBrush" Value="{DynamicResource BevelDarkBrush}"/>
|
||||||
|
<Setter TargetName="ComboChevDark" Property="BorderBrush" Value="{DynamicResource BevelLightBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsEnabled" Value="False">
|
||||||
|
<Setter TargetName="Bd" Property="Opacity" Value="0.4"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
</ResourceDictionary>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,302 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Data;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using KillerPDF.Services.Signing;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Themed modal dialog that cryptographically signs the open PDF with a certificate (a .pfx/.p12
|
||||||
|
/// file, or one from the Windows store) and writes a NEW signed copy. This is the real digital
|
||||||
|
/// signature - distinct from the drawn "Signature" stamp tool, which only places a picture.
|
||||||
|
/// Chrome and colors mirror PrintPreviewWindow so every KillerPDF dialog looks identical.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SignDocumentDialog : Window
|
||||||
|
{
|
||||||
|
private readonly string _sourcePdf;
|
||||||
|
|
||||||
|
private RadioButton _fileRadio = null!;
|
||||||
|
private RadioButton _storeRadio = null!;
|
||||||
|
private TextBox _pfxBox = null!;
|
||||||
|
private PasswordBox _pwBox = null!;
|
||||||
|
private Button _browsePfx = null!;
|
||||||
|
private ComboBox _storeCombo = null!;
|
||||||
|
private TextBox _reasonBox = null!;
|
||||||
|
private TextBox _locationBox = null!;
|
||||||
|
private TextBox _contactBox = null!;
|
||||||
|
private TextBox _outputBox = null!;
|
||||||
|
private readonly List<X509Certificate2> _storeCerts = [];
|
||||||
|
|
||||||
|
// Segoe MDL2 Assets close glyph, matching the main window + print dialog chrome.
|
||||||
|
private const string CloseGlyph = "";
|
||||||
|
|
||||||
|
private static SolidColorBrush R(string key) => (SolidColorBrush)Application.Current.Resources[key];
|
||||||
|
|
||||||
|
// Localized string from the active locale dictionary (falls back to the key if missing).
|
||||||
|
private static string L(string key) => Application.Current.TryFindResource(key) as string ?? key;
|
||||||
|
|
||||||
|
public SignDocumentDialog(Window? owner, string sourcePdf)
|
||||||
|
{
|
||||||
|
_sourcePdf = sourcePdf;
|
||||||
|
Title = "KillerPDF - " + L("Str_Sign_Name");
|
||||||
|
Width = 470;
|
||||||
|
SizeToContent = SizeToContent.Height;
|
||||||
|
UseLayoutRounding = true;
|
||||||
|
DialogChrome.Configure(this, owner);
|
||||||
|
BuildUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildUi()
|
||||||
|
{
|
||||||
|
var body = new StackPanel { Margin = new Thickness(20, 6, 20, 18) };
|
||||||
|
|
||||||
|
body.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = string.Format(L("Str_Sign_Desc"), Path.GetFileName(_sourcePdf)),
|
||||||
|
Foreground = R("MutedTextBrush"), FontSize = 11, TextWrapping = TextWrapping.Wrap,
|
||||||
|
Margin = new Thickness(0, 0, 0, 14)
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Certificate source --------------------------------------------------------------
|
||||||
|
body.Children.Add(Label(L("Str_Sign_Certificate")));
|
||||||
|
|
||||||
|
_fileRadio = Radio(L("Str_Sign_FromFile"), true);
|
||||||
|
_storeRadio = Radio(L("Str_Sign_FromStore"), false);
|
||||||
|
_fileRadio.Checked += (_, _) => SyncSource();
|
||||||
|
_storeRadio.Checked += (_, _) => SyncSource();
|
||||||
|
body.Children.Add(_fileRadio);
|
||||||
|
|
||||||
|
var fileRow = new Grid { Margin = new Thickness(20, 2, 0, 4) };
|
||||||
|
fileRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||||
|
fileRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
_pfxBox = Field("");
|
||||||
|
_pfxBox.Margin = new Thickness(0, 0, 6, 0);
|
||||||
|
Grid.SetColumn(_pfxBox, 0);
|
||||||
|
_browsePfx = MakeButton(L("Str_Sign_Browse"), false);
|
||||||
|
_browsePfx.Click += (_, _) => BrowsePfx();
|
||||||
|
Grid.SetColumn(_browsePfx, 1);
|
||||||
|
fileRow.Children.Add(_pfxBox);
|
||||||
|
fileRow.Children.Add(_browsePfx);
|
||||||
|
body.Children.Add(fileRow);
|
||||||
|
|
||||||
|
body.Children.Add(new TextBlock { Text = L("Str_Sign_Password"), Foreground = R("MutedTextBrush"), FontSize = 11, Margin = new Thickness(20, 4, 0, 2) });
|
||||||
|
_pwBox = new PasswordBox
|
||||||
|
{
|
||||||
|
Margin = new Thickness(20, 0, 0, 10),
|
||||||
|
Background = R("BgCanvas"), Foreground = R("TextBrush"),
|
||||||
|
BorderBrush = R("CardBorderBrush"), BorderThickness = new Thickness(1),
|
||||||
|
CaretBrush = R("TextBrush"), Template = MakePasswordTemplate()
|
||||||
|
};
|
||||||
|
body.Children.Add(_pwBox);
|
||||||
|
|
||||||
|
body.Children.Add(_storeRadio);
|
||||||
|
_storeCombo = new ComboBox { Margin = new Thickness(20, 2, 0, 10), Height = 26 };
|
||||||
|
ApplyComboStyle(_storeCombo);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var c in WindowsCertificateStore.ListSigningCertificates())
|
||||||
|
{
|
||||||
|
_storeCerts.Add(c);
|
||||||
|
_storeCombo.Items.Add(new StoreCertificateProvider(c).DisplayName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* store unavailable - leave empty */ }
|
||||||
|
if (_storeCombo.Items.Count > 0) _storeCombo.SelectedIndex = 0;
|
||||||
|
body.Children.Add(_storeCombo);
|
||||||
|
|
||||||
|
// --- Metadata ------------------------------------------------------------------------
|
||||||
|
body.Children.Add(Label(L("Str_Sign_Reason")));
|
||||||
|
_reasonBox = Field(""); body.Children.Add(_reasonBox);
|
||||||
|
body.Children.Add(Label(L("Str_Sign_Location")));
|
||||||
|
_locationBox = Field(""); body.Children.Add(_locationBox);
|
||||||
|
body.Children.Add(Label(L("Str_Sign_Contact")));
|
||||||
|
_contactBox = Field(""); body.Children.Add(_contactBox);
|
||||||
|
|
||||||
|
// --- Output --------------------------------------------------------------------------
|
||||||
|
body.Children.Add(Label(L("Str_Sign_SaveAs")));
|
||||||
|
var outRow = new Grid();
|
||||||
|
outRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||||
|
outRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
_outputBox = Field(DefaultOutputPath());
|
||||||
|
_outputBox.Margin = new Thickness(0, 0, 6, 0);
|
||||||
|
Grid.SetColumn(_outputBox, 0);
|
||||||
|
var browseOut = MakeButton(L("Str_Sign_Browse"), false);
|
||||||
|
browseOut.Click += (_, _) => BrowseOutput();
|
||||||
|
Grid.SetColumn(browseOut, 1);
|
||||||
|
outRow.Children.Add(_outputBox);
|
||||||
|
outRow.Children.Add(browseOut);
|
||||||
|
body.Children.Add(outRow);
|
||||||
|
|
||||||
|
// --- Buttons -------------------------------------------------------------------------
|
||||||
|
var btnRow = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 16, 0, 0) };
|
||||||
|
var sign = MakeButton(L("Str_Sign_Sign"), true);
|
||||||
|
sign.Click += (_, _) => DoSign();
|
||||||
|
sign.IsDefault = true; // Enter
|
||||||
|
var cancel = MakeButton(L("Str_Sign_Cancel"), false);
|
||||||
|
cancel.Margin = new Thickness(8, 0, 0, 0);
|
||||||
|
cancel.Click += (_, _) => { DialogResult = false; Close(); };
|
||||||
|
cancel.IsCancel = true; // Esc
|
||||||
|
btnRow.Children.Add(sign);
|
||||||
|
btnRow.Children.Add(cancel);
|
||||||
|
body.Children.Add(btnRow);
|
||||||
|
|
||||||
|
SyncSource();
|
||||||
|
|
||||||
|
Content = DialogChrome.Frame(this, Owner, "KillerPDF - " + L("Str_Sign_TitleSuffix"),
|
||||||
|
() => { DialogResult = false; Close(); }, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string DefaultOutputPath()
|
||||||
|
{
|
||||||
|
string dir = Path.GetDirectoryName(_sourcePdf) ?? "";
|
||||||
|
string name = Path.GetFileNameWithoutExtension(_sourcePdf);
|
||||||
|
return Path.Combine(dir, name + "-signed.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable only the inputs for the selected certificate source.
|
||||||
|
private void SyncSource()
|
||||||
|
{
|
||||||
|
bool file = _fileRadio.IsChecked == true;
|
||||||
|
_pfxBox.IsEnabled = _browsePfx.IsEnabled = _pwBox.IsEnabled = file;
|
||||||
|
_storeCombo.IsEnabled = !file;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BrowsePfx()
|
||||||
|
{
|
||||||
|
var dlg = new KillerPDF.Controls.FileDialog(KillerPDF.Controls.FileDialogMode.Open)
|
||||||
|
{ Filter = L("Str_Filter_Cert") + "|*.pfx;*.p12|" + L("Str_Filter_AllFiles") + "|*.*", Title = L("Str_Sign_ChooseCert") };
|
||||||
|
if (dlg.ShowDialog(this) == true) _pfxBox.Text = dlg.FileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BrowseOutput()
|
||||||
|
{
|
||||||
|
var dlg = new KillerPDF.Controls.FileDialog(KillerPDF.Controls.FileDialogMode.Save)
|
||||||
|
{ Filter = L("Str_Filter_Pdf") + "|*.pdf", Title = L("Str_Sign_SaveAs"), FileName = Path.GetFileName(_outputBox.Text) };
|
||||||
|
if (dlg.ShowDialog(this) == true) _outputBox.Text = dlg.FileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DoSign()
|
||||||
|
{
|
||||||
|
ICertificateProvider provider;
|
||||||
|
if (_fileRadio.IsChecked == true)
|
||||||
|
{
|
||||||
|
string pfx = _pfxBox.Text?.Trim() ?? "";
|
||||||
|
if (!File.Exists(pfx)) { Warn(L("Str_Sign_NeedCertFile")); return; }
|
||||||
|
provider = new PfxFileCertificateProvider(pfx, _pwBox.Password);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
int i = _storeCombo.SelectedIndex;
|
||||||
|
if (i < 0 || i >= _storeCerts.Count) { Warn(L("Str_Sign_NoStoreCert")); return; }
|
||||||
|
provider = new StoreCertificateProvider(_storeCerts[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
string output = _outputBox.Text?.Trim() ?? "";
|
||||||
|
if (string.IsNullOrEmpty(output)) { Warn(L("Str_Sign_NeedOutput")); return; }
|
||||||
|
|
||||||
|
X509Certificate2 cert;
|
||||||
|
try { cert = provider.GetCertificate(); }
|
||||||
|
catch (System.Security.Cryptography.CryptographicException)
|
||||||
|
{
|
||||||
|
// The raw Win32 text ("The specified network password is not correct.") is misleading -
|
||||||
|
// nothing networked is involved. Almost always a wrong password or a non-.pfx file.
|
||||||
|
Warn(L("Str_Sign_BadCert"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Warn(L("Str_Sign_CertLoadFailed") + "\n\n" + ex.Message); return; }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
new PdfSigner().Sign(_sourcePdf, output, cert,
|
||||||
|
new PdfSigner.SignInfo(_reasonBox.Text ?? "", _locationBox.Text ?? "", _contactBox.Text ?? ""));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Warn(L("Str_Sign_Failed") + "\n\n" + ex.GetType().Name + ": " + ex.Message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
KillerDialog.Show(this, L("Str_Dlg_SignedSavedTo") + "\n" + output, L("Str_Sign_Name"), MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
DialogResult = true;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Warn(string msg) => KillerDialog.Show(this, msg, L("Str_Sign_Name"), MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
|
||||||
|
// ---- themed control helpers (mirroring PrintPreviewWindow) -------------------------------
|
||||||
|
private Style? FindOwnerStyle(string key) => Owner?.TryFindResource(key) as Style;
|
||||||
|
|
||||||
|
private static TextBlock Label(string text) => new()
|
||||||
|
{ Text = text, Foreground = R("TextBrush"), FontSize = 12, FontWeight = FontWeights.SemiBold, Margin = new Thickness(0, 6, 0, 2) };
|
||||||
|
|
||||||
|
private RadioButton Radio(string text, bool isChecked)
|
||||||
|
{
|
||||||
|
var r = new RadioButton { Content = text, IsChecked = isChecked, GroupName = "CertSource", FontSize = 12, Margin = new Thickness(0, 4, 0, 2) };
|
||||||
|
if (FindOwnerStyle("ThemeRadio") is Style s) r.Style = s; else r.Foreground = R("TextBrush");
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyComboStyle(ComboBox combo)
|
||||||
|
{
|
||||||
|
if (FindOwnerStyle("DarkComboBox") is Style s) combo.Style = s;
|
||||||
|
else { combo.Foreground = R("TextBrush"); combo.BorderBrush = R("CardBorderBrush"); }
|
||||||
|
combo.Background = R("BgCanvas");
|
||||||
|
}
|
||||||
|
|
||||||
|
private TextBox Field(string text)
|
||||||
|
{
|
||||||
|
var tb = new TextBox
|
||||||
|
{
|
||||||
|
Text = text, Margin = new Thickness(0, 0, 0, 4),
|
||||||
|
Background = R("BgCanvas"), Foreground = R("TextBrush"),
|
||||||
|
BorderBrush = R("CardBorderBrush"), BorderThickness = new Thickness(1),
|
||||||
|
Padding = new Thickness(6, 4, 6, 4), CaretBrush = R("TextBrush"),
|
||||||
|
SelectionBrush = R("RowSelectedBrush"), SelectionTextBrush = R("TextBrush"),
|
||||||
|
Template = MakeTextBoxTemplate()
|
||||||
|
};
|
||||||
|
return tb;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ControlTemplate MakeTextBoxTemplate()
|
||||||
|
{
|
||||||
|
var b = new FrameworkElementFactory(typeof(Border));
|
||||||
|
b.SetBinding(Border.BackgroundProperty, new Binding("Background") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
|
||||||
|
b.SetBinding(Border.BorderBrushProperty, new Binding("BorderBrush") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
|
||||||
|
b.SetBinding(Border.BorderThicknessProperty, new Binding("BorderThickness") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
|
||||||
|
b.SetValue(Border.CornerRadiusProperty, new CornerRadius(3));
|
||||||
|
var sv = new FrameworkElementFactory(typeof(ScrollViewer)) { Name = "PART_ContentHost" };
|
||||||
|
b.AppendChild(sv);
|
||||||
|
var ct = new ControlTemplate(typeof(TextBox)) { VisualTree = b };
|
||||||
|
var disabled = new Trigger { Property = UIElement.IsEnabledProperty, Value = false };
|
||||||
|
disabled.Setters.Add(new Setter(UIElement.OpacityProperty, 0.4));
|
||||||
|
ct.Triggers.Add(disabled);
|
||||||
|
return ct;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ControlTemplate MakePasswordTemplate()
|
||||||
|
{
|
||||||
|
var b = new FrameworkElementFactory(typeof(Border));
|
||||||
|
b.SetBinding(Border.BackgroundProperty, new Binding("Background") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
|
||||||
|
b.SetBinding(Border.BorderBrushProperty, new Binding("BorderBrush") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
|
||||||
|
b.SetBinding(Border.BorderThicknessProperty, new Binding("BorderThickness") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
|
||||||
|
b.SetValue(Border.CornerRadiusProperty, new CornerRadius(3));
|
||||||
|
b.SetValue(Border.PaddingProperty, new Thickness(6, 4, 6, 4));
|
||||||
|
var sv = new FrameworkElementFactory(typeof(ScrollViewer)) { Name = "PART_ContentHost" };
|
||||||
|
b.AppendChild(sv);
|
||||||
|
var ct = new ControlTemplate(typeof(PasswordBox)) { VisualTree = b };
|
||||||
|
var disabled = new Trigger { Property = UIElement.IsEnabledProperty, Value = false };
|
||||||
|
disabled.Setters.Add(new Setter(UIElement.OpacityProperty, 0.4));
|
||||||
|
ct.Triggers.Add(disabled);
|
||||||
|
return ct;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Button MakeButton(string label, bool accent) => UiKit.Make(label, accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,831 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Combined "Stamp" tool, modeled on the Transform window: a live page preview on the left and an
|
||||||
|
/// options sidebar on the right with two independent, toggleable sections - Page Numbers and
|
||||||
|
/// Watermark (text or image). Apply hands a StampSpec back to the caller, which places the stamps on
|
||||||
|
/// the editable stamp layer. Re-opening (double-click a stamp) seeds the window from the saved spec.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class StampWindow : Window
|
||||||
|
{
|
||||||
|
public bool Applied { get; private set; }
|
||||||
|
public StampSpec Result { get; private set; }
|
||||||
|
|
||||||
|
private BitmapSource _pageSrc;
|
||||||
|
private double _pageWpt, _pageHpt;
|
||||||
|
private readonly int _pageCount;
|
||||||
|
private int _pageIndex;
|
||||||
|
private readonly StampSpec _spec;
|
||||||
|
private readonly bool _hadExisting; // dialog opened on a doc that already has stamps (#145)
|
||||||
|
// Renders an arbitrary page for the preview stepper: returns that page's bitmap + size in points.
|
||||||
|
private readonly Func<int, (BitmapSource? src, double wpt, double hpt)>? _pageProvider;
|
||||||
|
private TextBlock _pageNavLabel = null!;
|
||||||
|
private Button _prevArrow = null!, _nextArrow = null!;
|
||||||
|
private System.Windows.Threading.DispatcherTimer? _navRenderTimer;
|
||||||
|
|
||||||
|
private readonly Image _preview = new()
|
||||||
|
{
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Margin = new Thickness(24),
|
||||||
|
Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, BlurRadius = 14, ShadowDepth = 3, Direction = 270, Opacity = 0.4 }
|
||||||
|
};
|
||||||
|
private readonly Canvas _overlay = new() { IsHitTestVisible = false, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
|
||||||
|
private FrameworkElement _previewArea = null!;
|
||||||
|
private Button _applyBtn = null!;
|
||||||
|
private readonly System.Windows.Threading.DispatcherTimer _previewTimer;
|
||||||
|
|
||||||
|
// Page-number controls
|
||||||
|
private CheckBox _numEnable = null!;
|
||||||
|
private CheckBox _numMirror = null!;
|
||||||
|
private TextBox _numStart = null!, _numFormat = null!, _numSize = null!, _numRange = null!;
|
||||||
|
private ComboBox _numPos = null!;
|
||||||
|
private Border _numSwatch = null!;
|
||||||
|
private Color _numColor;
|
||||||
|
private StackPanel _numBody = null!;
|
||||||
|
|
||||||
|
// Watermark controls
|
||||||
|
private CheckBox _wmEnable = null!;
|
||||||
|
private RadioButton _wmTextRadio = null!, _wmImageRadio = null!;
|
||||||
|
private TextBox _wmText = null!, _wmSize = null!, _wmRange = null!;
|
||||||
|
private ComboBox _wmPos = null!, _wmFont = null!;
|
||||||
|
private Slider _wmAngle = null!, _wmOpacity = null!, _wmScale = null!;
|
||||||
|
private Border _wmSwatch = null!;
|
||||||
|
private Color _wmColor;
|
||||||
|
private string? _wmImagePath;
|
||||||
|
private BitmapImage? _wmImageSrc;
|
||||||
|
private TextBlock _wmImageLabel = null!;
|
||||||
|
private StackPanel _wmBody = null!, _wmTextPanel = null!, _wmImagePanel = null!;
|
||||||
|
|
||||||
|
private readonly Style? _darkSlider, _darkCombo;
|
||||||
|
|
||||||
|
private static SolidColorBrush R(string key) => (SolidColorBrush)Application.Current.Resources[key];
|
||||||
|
private static string S(string key) => Application.Current.TryFindResource(key) as string ?? key;
|
||||||
|
|
||||||
|
// (resource key, horizontal 0/1/2, vertical 0 top / 1 middle / 2 bottom)
|
||||||
|
private static readonly (string key, int h, int v)[] Positions =
|
||||||
|
[
|
||||||
|
("Str_Pos_BottomCenter", 1, 2), ("Str_Pos_BottomRight", 2, 2), ("Str_Pos_BottomLeft", 0, 2),
|
||||||
|
("Str_Pos_TopCenter", 1, 0), ("Str_Pos_TopRight", 2, 0), ("Str_Pos_TopLeft", 0, 0),
|
||||||
|
("Str_Pos_Center", 1, 1), ("Str_Pos_Custom", -1, -1)
|
||||||
|
];
|
||||||
|
|
||||||
|
public StampWindow(Window owner, BitmapSource pageSrc, double pageWpt, double pageHpt,
|
||||||
|
int pageCount, int pageIndex, StampSpec? existing,
|
||||||
|
Func<int, (BitmapSource? src, double wpt, double hpt)>? pageProvider = null)
|
||||||
|
{
|
||||||
|
_pageSrc = pageSrc;
|
||||||
|
_pageWpt = pageWpt;
|
||||||
|
_pageHpt = pageHpt;
|
||||||
|
_pageCount = pageCount;
|
||||||
|
_pageIndex = pageIndex;
|
||||||
|
_pageProvider = pageProvider;
|
||||||
|
_hadExisting = existing is not null; // #145: clearing existing stamps must stay applyable
|
||||||
|
_spec = existing?.Clone() ?? new StampSpec { NumbersEnabled = true };
|
||||||
|
Result = _spec;
|
||||||
|
|
||||||
|
Title = "KillerPDF - " + S("Str_Stamp_Suffix");
|
||||||
|
Width = 980;
|
||||||
|
Height = 720;
|
||||||
|
MinWidth = 680;
|
||||||
|
MinHeight = 480;
|
||||||
|
DialogChrome.Configure(this, owner, resizable: true);
|
||||||
|
|
||||||
|
_darkSlider = owner.TryFindResource("DarkSlider") as Style;
|
||||||
|
_darkCombo = owner.TryFindResource("DarkComboBox") as Style;
|
||||||
|
|
||||||
|
// Borrow the main window's themed scrollbar so the sidebar scroller isn't the OS-white default.
|
||||||
|
if (owner.TryFindResource(typeof(System.Windows.Controls.Primitives.ScrollBar)) is Style sbStyle)
|
||||||
|
Resources[typeof(System.Windows.Controls.Primitives.ScrollBar)] = sbStyle;
|
||||||
|
|
||||||
|
_numColor = _spec.NumColor;
|
||||||
|
_wmColor = _spec.WmColor;
|
||||||
|
_wmImagePath = _spec.WmImagePath;
|
||||||
|
|
||||||
|
_previewTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromMilliseconds(40) };
|
||||||
|
_previewTimer.Tick += (_, _2) => { _previewTimer.Stop(); RenderPreview(); };
|
||||||
|
|
||||||
|
BuildUi(owner);
|
||||||
|
LoadWatermarkImage();
|
||||||
|
UpdateEnabledStates();
|
||||||
|
RenderPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Schedule() { _previewTimer.Stop(); _previewTimer.Start(); }
|
||||||
|
|
||||||
|
private void BuildUi(Window owner)
|
||||||
|
{
|
||||||
|
var root = new DockPanel();
|
||||||
|
|
||||||
|
// ---- Right sidebar ----
|
||||||
|
// Small right padding so the always-on scrollbar tucks near the window edge; the footer and
|
||||||
|
// scrolled content get their own right inset so nothing sits under the bar.
|
||||||
|
var sidebar = new Border { Width = 300, Background = Brushes.Transparent, Padding = new Thickness(16, 8, 4, 14) };
|
||||||
|
DockPanel.SetDock(sidebar, Dock.Right);
|
||||||
|
var side = new DockPanel();
|
||||||
|
|
||||||
|
// Docked footer: Reset all link above a right-aligned Cancel / Apply row. Right inset keeps the
|
||||||
|
// buttons off the reserved scrollbar gutter.
|
||||||
|
var bottom = new StackPanel { Margin = new Thickness(0, 10, 12, 0) };
|
||||||
|
var resetLink = UiKit.LinkLabel(S("Str_Tf_ResetAll"), ResetAll);
|
||||||
|
resetLink.Margin = new Thickness(0, 0, 0, 8);
|
||||||
|
bottom.Children.Add(resetLink);
|
||||||
|
var actionRow = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right };
|
||||||
|
var cancelBtn = UiKit.Make(S("Str_Tf_Cancel"), false);
|
||||||
|
cancelBtn.Click += (_, _2) => { Applied = false; Close(); };
|
||||||
|
cancelBtn.IsCancel = true; // Esc
|
||||||
|
cancelBtn.Margin = new Thickness(0, 0, 8, 0);
|
||||||
|
actionRow.Children.Add(cancelBtn);
|
||||||
|
_applyBtn = UiKit.Make(S("Str_Tf_Apply"), true);
|
||||||
|
_applyBtn.Click += (_, _2) => CommitAndClose();
|
||||||
|
_applyBtn.IsDefault = true; // Enter
|
||||||
|
actionRow.Children.Add(_applyBtn);
|
||||||
|
bottom.Children.Add(actionRow);
|
||||||
|
DockPanel.SetDock(bottom, Dock.Bottom);
|
||||||
|
side.Children.Add(bottom);
|
||||||
|
|
||||||
|
// Keep fields and section headers clear of the always-reserved scrollbar gutter.
|
||||||
|
var stack = new StackPanel { Margin = new Thickness(0, 0, 8, 0) };
|
||||||
|
stack.Children.Add(BuildWatermarkSection());
|
||||||
|
stack.Children.Add(Divider());
|
||||||
|
stack.Children.Add(BuildNumbersSection());
|
||||||
|
|
||||||
|
// Scrollbar is ALWAYS reserved (Visible, not Auto) so the content never shifts left when it
|
||||||
|
// appears. This is the rule for these sidebar windows.
|
||||||
|
var scroller = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Visible, Content = stack };
|
||||||
|
side.Children.Add(scroller);
|
||||||
|
sidebar.Child = side;
|
||||||
|
root.Children.Add(sidebar);
|
||||||
|
|
||||||
|
// ---- Left preview ----
|
||||||
|
var previewWrap = new Border
|
||||||
|
{
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = UiKit.RadControl,
|
||||||
|
Margin = new Thickness(8, 4, 8, 12),
|
||||||
|
ClipToBounds = true
|
||||||
|
};
|
||||||
|
previewWrap.SetResourceReference(Border.BackgroundProperty, "BgCanvas");
|
||||||
|
previewWrap.SetResourceReference(Border.BorderBrushProperty, "PaneBorderBrush");
|
||||||
|
|
||||||
|
// The page image (row 0) and the stepper (row 1) live in separate rows so the stepper sits
|
||||||
|
// BELOW the page instead of overlapping it - same row layout as the print preview.
|
||||||
|
var previewLayout = new Grid();
|
||||||
|
previewLayout.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
|
||||||
|
previewLayout.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
|
||||||
|
|
||||||
|
var imageHost = new Grid();
|
||||||
|
AddGrain(imageHost, owner, 0.05, cornerRadius: 0);
|
||||||
|
RenderOptions.SetBitmapScalingMode(_preview, BitmapScalingMode.HighQuality);
|
||||||
|
_preview.Source = _pageSrc;
|
||||||
|
imageHost.Children.Add(_preview);
|
||||||
|
imageHost.Children.Add(_overlay);
|
||||||
|
Grid.SetRow(imageHost, 0);
|
||||||
|
previewLayout.Children.Add(imageHost);
|
||||||
|
previewWrap.Child = previewLayout;
|
||||||
|
_previewArea = imageHost; // size the page against the image row only, never the stepper row
|
||||||
|
// Page stepper: the wheel over the preview (or the arrows) walks pages so you can preview the
|
||||||
|
// stamp on any page - the per-page number and range checks update as you go. Only when the caller
|
||||||
|
// supplies a page provider and there's more than one page.
|
||||||
|
if (_pageProvider != null && _pageCount > 1)
|
||||||
|
{
|
||||||
|
var nav = BuildPageNav();
|
||||||
|
Grid.SetRow(nav, 1);
|
||||||
|
previewLayout.Children.Add(nav);
|
||||||
|
previewWrap.PreviewMouseWheel += (_, e) =>
|
||||||
|
{
|
||||||
|
int notches = Math.Max(1, Math.Abs(e.Delta) / 120);
|
||||||
|
StepPage(e.Delta < 0 ? notches : -notches);
|
||||||
|
e.Handled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_previewArea.SizeChanged += (_, _2) => { SizePreviewImage(); Schedule(); };
|
||||||
|
// Family shadow under the content pane, like the main window (flat on 98SE).
|
||||||
|
root.Children.Add(UiKit.PaneWithShadow(previewWrap));
|
||||||
|
|
||||||
|
Content = DialogChrome.Frame(this, Owner, "KillerPDF - " + S("Str_Stamp_Suffix"), () => { Applied = false; Close(); }, root);
|
||||||
|
|
||||||
|
// Esc-to-close is wired by DialogChrome.Frame; Enter commits.
|
||||||
|
KeyDown += (_, e) => { if (e.Key == Key.Enter) CommitAndClose(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddGrain(Grid host, Window owner, double fallback, double cornerRadius)
|
||||||
|
{
|
||||||
|
var grain = (owner as MainWindow)?.GrainTexture;
|
||||||
|
if (grain == null) return;
|
||||||
|
double op = Application.Current.Resources["GrainOpacity"] is double go ? go : fallback;
|
||||||
|
host.Children.Add(new Border
|
||||||
|
{
|
||||||
|
CornerRadius = new CornerRadius(cornerRadius), IsHitTestVisible = false, Opacity = op,
|
||||||
|
Background = new ImageBrush(grain) { TileMode = TileMode.Tile, ViewportUnits = BrushMappingMode.Absolute, Viewport = new Rect(0, 0, 256, 256), Stretch = Stretch.None }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Page Numbers section ----------
|
||||||
|
private FrameworkElement BuildNumbersSection()
|
||||||
|
{
|
||||||
|
var wrap = new StackPanel();
|
||||||
|
_numBody = new StackPanel { Margin = new Thickness(0, 4, 0, 0) };
|
||||||
|
_numEnable = SectionToggle(S("Str_Stamp_SecNumbers"), _spec.NumbersEnabled);
|
||||||
|
_numEnable.Checked += (_, _2) => { UpdateEnabledStates(); Schedule(); };
|
||||||
|
_numEnable.Unchecked += (_, _2) => { UpdateEnabledStates(); Schedule(); };
|
||||||
|
wrap.Children.Add(SectionHeaderRow(_numEnable, _numBody));
|
||||||
|
|
||||||
|
_numBody.Children.Add(UiKit.GroupLabel(S("Str_Stamp_StartAt")));
|
||||||
|
_numStart = UiKit.Field();
|
||||||
|
_numStart.Text = _spec.StartNumber.ToString();
|
||||||
|
_numStart.Margin = new Thickness(0, 0, 0, 8);
|
||||||
|
_numStart.TextChanged += (_, _2) => Schedule();
|
||||||
|
_numBody.Children.Add(_numStart);
|
||||||
|
|
||||||
|
_numBody.Children.Add(UiKit.GroupLabel(S("Str_Stamp_Format")));
|
||||||
|
_numFormat = UiKit.Field();
|
||||||
|
_numFormat.Text = _spec.Format;
|
||||||
|
_numFormat.TextChanged += (_, _2) => Schedule();
|
||||||
|
_numBody.Children.Add(_numFormat);
|
||||||
|
_numBody.Children.Add(new TextBlock { Text = S("Str_Stamp_Hint"), Foreground = R("MutedTextBrush"), FontSize = 11, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 2, 0, 0) });
|
||||||
|
_numBody.Children.Add(new TextBlock { Text = S("Str_Stamp_Hint2"), Foreground = R("MutedTextBrush"), FontSize = 11, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 8) });
|
||||||
|
|
||||||
|
_numBody.Children.Add(SliderBoxRow(S("Str_Stamp_FontSize"), 6, 96, _spec.NumFontPt, out _, out _numSize));
|
||||||
|
|
||||||
|
_numBody.Children.Add(ColorRow(S("Str_Stamp_Color"), _numColor, out _numSwatch, c => { _numColor = c; Schedule(); }));
|
||||||
|
|
||||||
|
_numBody.Children.Add(UiKit.GroupLabel(S("Str_Stamp_Pages")));
|
||||||
|
_numRange = UiKit.Field();
|
||||||
|
_numRange.Text = _spec.NumRange;
|
||||||
|
_numRange.ToolTip = S("Str_Crop_RangeTip");
|
||||||
|
_numRange.Margin = new Thickness(0, 0, 0, 8);
|
||||||
|
_numRange.TextChanged += (_, _2) => Schedule();
|
||||||
|
_numBody.Children.Add(_numRange);
|
||||||
|
|
||||||
|
// Position is the last page-number option (it's the least-changed setting).
|
||||||
|
_numBody.Children.Add(UiKit.GroupLabel(S("Str_Stamp_Position")));
|
||||||
|
_numPos = MakePosCombo(_spec.NumPosH, _spec.NumPosV);
|
||||||
|
_numPos.SelectionChanged += (_, _2) => { UpdateMirrorEnabled(); Schedule(); };
|
||||||
|
_numBody.Children.Add(_numPos);
|
||||||
|
|
||||||
|
_numMirror = UiKit.CheckBox(S("Str_Stamp_Mirror"));
|
||||||
|
_numMirror.IsChecked = _spec.NumMirror;
|
||||||
|
_numMirror.Margin = new Thickness(0, 6, 0, 0);
|
||||||
|
_numMirror.Checked += (_, _2) => Schedule();
|
||||||
|
_numMirror.Unchecked += (_, _2) => Schedule();
|
||||||
|
_numBody.Children.Add(_numMirror);
|
||||||
|
UpdateMirrorEnabled();
|
||||||
|
|
||||||
|
wrap.Children.Add(_numBody);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Watermark section ----------
|
||||||
|
private FrameworkElement BuildWatermarkSection()
|
||||||
|
{
|
||||||
|
var wrap = new StackPanel();
|
||||||
|
_wmBody = new StackPanel { Margin = new Thickness(0, 4, 0, 0) };
|
||||||
|
_wmEnable = SectionToggle(S("Str_Stamp_SecWatermark"), _spec.WmEnabled);
|
||||||
|
_wmEnable.Checked += (_, _2) => { UpdateEnabledStates(); Schedule(); };
|
||||||
|
_wmEnable.Unchecked += (_, _2) => { UpdateEnabledStates(); Schedule(); };
|
||||||
|
wrap.Children.Add(SectionHeaderRow(_wmEnable, _wmBody));
|
||||||
|
|
||||||
|
// Type: text vs image (clean UiKit radios line up with the section content directly).
|
||||||
|
var typeRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 4, 0, 6) };
|
||||||
|
_wmTextRadio = MakeRadio(S("Str_Stamp_WmText"), !_spec.WmIsImage);
|
||||||
|
_wmTextRadio.Margin = new Thickness(0, 0, 14, 0);
|
||||||
|
_wmImageRadio = MakeRadio(S("Str_Stamp_WmImage"), _spec.WmIsImage);
|
||||||
|
_wmTextRadio.Checked += (_, _2) => { UpdateEnabledStates(); Schedule(); };
|
||||||
|
_wmImageRadio.Checked += (_, _2) => { UpdateEnabledStates(); Schedule(); };
|
||||||
|
typeRow.Children.Add(_wmTextRadio);
|
||||||
|
typeRow.Children.Add(_wmImageRadio);
|
||||||
|
_wmBody.Children.Add(typeRow);
|
||||||
|
|
||||||
|
// Text sub-panel
|
||||||
|
_wmTextPanel = new StackPanel();
|
||||||
|
_wmTextPanel.Children.Add(UiKit.GroupLabel(S("Str_Stamp_WmTextLabel")));
|
||||||
|
_wmText = UiKit.Field();
|
||||||
|
_wmText.Text = _spec.WmText;
|
||||||
|
_wmText.Margin = new Thickness(0, 0, 0, 8);
|
||||||
|
_wmText.TextChanged += (_, _2) => Schedule();
|
||||||
|
_wmTextPanel.Children.Add(_wmText);
|
||||||
|
|
||||||
|
var wmFontRow = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 0, 8) };
|
||||||
|
wmFontRow.Children.Add(new TextBlock { Text = S("Str_Bar_Font"), Foreground = R("MutedTextBrush"), FontFamily = UiKit.UiFont, FontSize = 11, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0) });
|
||||||
|
_wmFont = new ComboBox { Width = 188, Height = 26, MaxDropDownHeight = 320, VerticalAlignment = VerticalAlignment.Center };
|
||||||
|
if (_darkCombo != null) _wmFont.Style = _darkCombo; else { _wmFont.Background = R("BgCanvas"); _wmFont.Foreground = R("TextBrush"); }
|
||||||
|
foreach (var fn in MainWindow.SystemFontNames) _wmFont.Items.Add(fn);
|
||||||
|
_wmFont.SelectedItem = _spec.WmFont;
|
||||||
|
_wmFont.SelectionChanged += (_, _2) => Schedule();
|
||||||
|
wmFontRow.Children.Add(_wmFont);
|
||||||
|
_wmTextPanel.Children.Add(wmFontRow);
|
||||||
|
_wmTextPanel.Children.Add(SliderBoxRow(S("Str_Stamp_FontSize"), 12, 200, _spec.WmFontPt, out _, out _wmSize));
|
||||||
|
_wmTextPanel.Children.Add(ColorRow(S("Str_Stamp_Color"), _wmColor, out _wmSwatch, c => { _wmColor = c; Schedule(); }));
|
||||||
|
_wmBody.Children.Add(_wmTextPanel);
|
||||||
|
|
||||||
|
// Image sub-panel: filename fills the left, the Choose button sits right-aligned across from it.
|
||||||
|
_wmImagePanel = new StackPanel();
|
||||||
|
var imgRow = new DockPanel { Margin = new Thickness(0, 0, 0, 8) };
|
||||||
|
var chooseBtn = UiKit.Make(S("Str_Stamp_ChooseImage"), false);
|
||||||
|
chooseBtn.Click += (_, _2) => ChooseImage();
|
||||||
|
DockPanel.SetDock(chooseBtn, Dock.Right);
|
||||||
|
imgRow.Children.Add(chooseBtn);
|
||||||
|
_wmImageLabel = new TextBlock { Text = System.IO.Path.GetFileName(_wmImagePath ?? ""), Foreground = R("MutedTextBrush"), FontSize = 11, TextWrapping = TextWrapping.Wrap, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0) };
|
||||||
|
imgRow.Children.Add(_wmImageLabel);
|
||||||
|
_wmImagePanel.Children.Add(imgRow);
|
||||||
|
_wmImagePanel.Children.Add(SliderBoxRow(S("Str_Stamp_Scale"), 10, 200, _spec.WmScale * 100, out _wmScale, out _));
|
||||||
|
_wmBody.Children.Add(_wmImagePanel);
|
||||||
|
|
||||||
|
// Shared watermark controls
|
||||||
|
_wmBody.Children.Add(SliderBoxRow(S("Str_Stamp_Angle"), -90, 90, _spec.WmAngle, out _wmAngle, out _));
|
||||||
|
_wmBody.Children.Add(SliderBoxRow(S("Str_Stamp_Opacity"), 5, 100, _spec.WmOpacity * 100, out _wmOpacity, out _));
|
||||||
|
|
||||||
|
_wmBody.Children.Add(UiKit.GroupLabel(S("Str_Stamp_Position")));
|
||||||
|
_wmPos = MakePosCombo(_spec.WmPosH, _spec.WmPosV);
|
||||||
|
_wmPos.SelectionChanged += (_, _2) => Schedule();
|
||||||
|
_wmBody.Children.Add(_wmPos);
|
||||||
|
|
||||||
|
_wmBody.Children.Add(UiKit.GroupLabel(S("Str_Stamp_Pages")));
|
||||||
|
_wmRange = UiKit.Field();
|
||||||
|
_wmRange.Text = _spec.WmRange;
|
||||||
|
_wmRange.ToolTip = S("Str_Crop_RangeTip");
|
||||||
|
_wmRange.TextChanged += (_, _2) => Schedule();
|
||||||
|
_wmBody.Children.Add(_wmRange);
|
||||||
|
|
||||||
|
wrap.Children.Add(_wmBody);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- shared builders ----------
|
||||||
|
private CheckBox SectionToggle(string text, bool on)
|
||||||
|
{
|
||||||
|
var cb = UiKit.CheckBox(text);
|
||||||
|
cb.IsChecked = on;
|
||||||
|
cb.FontSize = 13;
|
||||||
|
cb.FontWeight = FontWeights.SemiBold;
|
||||||
|
cb.VerticalAlignment = VerticalAlignment.Center;
|
||||||
|
return cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapsible section header. The enable checkbox itself expands (checked) or collapses (unchecked)
|
||||||
|
// the body; the chevron is just a non-clickable indicator of that state.
|
||||||
|
private FrameworkElement SectionHeaderRow(CheckBox enable, StackPanel body)
|
||||||
|
{
|
||||||
|
var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 8, 0, 0) };
|
||||||
|
var chevron = new TextBlock
|
||||||
|
{
|
||||||
|
FontSize = 12, Foreground = R("MutedTextBrush"),
|
||||||
|
Width = 14, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0)
|
||||||
|
};
|
||||||
|
void Sync()
|
||||||
|
{
|
||||||
|
bool on = enable.IsChecked == true;
|
||||||
|
body.Visibility = on ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
chevron.Text = on ? "▾" : "▸"; // down when expanded, right when collapsed
|
||||||
|
}
|
||||||
|
enable.Checked += (_, _2) => Sync();
|
||||||
|
enable.Unchecked += (_, _2) => Sync();
|
||||||
|
Sync();
|
||||||
|
row.Children.Add(chevron);
|
||||||
|
row.Children.Add(enable);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A slider paired with a small numeric input box (two-way synced), e.g. font size.
|
||||||
|
private FrameworkElement SliderBoxRow(string label, double min, double max, double value, out Slider slider, out TextBox box)
|
||||||
|
{
|
||||||
|
var panel = new StackPanel { Margin = new Thickness(0, 2, 0, 8) };
|
||||||
|
panel.Children.Add(UiKit.GroupLabel(label));
|
||||||
|
var grid = new Grid();
|
||||||
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||||
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
var s = new Slider { Minimum = min, Maximum = max, Value = Math.Max(min, Math.Min(max, value)), SmallChange = 1, LargeChange = 4, VerticalAlignment = VerticalAlignment.Center };
|
||||||
|
if (_darkSlider != null) s.Style = _darkSlider;
|
||||||
|
var b = UiKit.Field(46);
|
||||||
|
b.Text = ((int)Math.Round(value)).ToString();
|
||||||
|
b.Margin = new Thickness(8, 0, 0, 0);
|
||||||
|
bool guard = false;
|
||||||
|
s.ValueChanged += (_, _2) => { if (guard) return; guard = true; b.Text = ((int)Math.Round(s.Value)).ToString(); guard = false; Schedule(); };
|
||||||
|
b.TextChanged += (_, _2) => { if (guard) return; if (double.TryParse(b.Text, out double d)) { guard = true; s.Value = Math.Max(min, Math.Min(max, d)); guard = false; Schedule(); } };
|
||||||
|
Grid.SetColumn(s, 0); Grid.SetColumn(b, 1);
|
||||||
|
grid.Children.Add(s); grid.Children.Add(b);
|
||||||
|
panel.Children.Add(grid);
|
||||||
|
slider = s; box = b;
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ComboBox MakePosCombo(int h, int v)
|
||||||
|
{
|
||||||
|
var combo = new ComboBox { Margin = new Thickness(0, 0, 0, 8), Height = 26 };
|
||||||
|
if (_darkCombo != null) combo.Style = _darkCombo; else { combo.Background = R("BgCanvas"); combo.Foreground = R("TextBrush"); }
|
||||||
|
int sel = 0;
|
||||||
|
for (int i = 0; i < Positions.Length; i++)
|
||||||
|
{
|
||||||
|
combo.Items.Add(S(Positions[i].key));
|
||||||
|
if (Positions[i].h == h && Positions[i].v == v) sel = i;
|
||||||
|
}
|
||||||
|
combo.SelectedIndex = sel;
|
||||||
|
return combo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RadioButton MakeRadio(string text, bool isChecked)
|
||||||
|
{
|
||||||
|
var rb = UiKit.Radio(text);
|
||||||
|
rb.IsChecked = isChecked;
|
||||||
|
return rb;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FrameworkElement ColorRow(string label, Color initial, out Border swatch, Action<Color> onPick)
|
||||||
|
{
|
||||||
|
var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 8), VerticalAlignment = VerticalAlignment.Center };
|
||||||
|
row.Children.Add(new TextBlock { Text = label, Foreground = R("MutedTextBrush"), FontFamily = UiKit.UiFont, FontSize = 11, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0) });
|
||||||
|
|
||||||
|
var sw = new Border
|
||||||
|
{
|
||||||
|
Width = 44, Height = 22, CornerRadius = UiKit.RadControl,
|
||||||
|
BorderBrush = R("CardBorderBrush"), BorderThickness = new Thickness(1),
|
||||||
|
Background = new SolidColorBrush(initial), SnapsToDevicePixels = true
|
||||||
|
};
|
||||||
|
|
||||||
|
// The swatch is a real Button (chrome-free template) so the click is rock-solid - a plain
|
||||||
|
// Border's MouseLeftButtonUp was unreliable here, which is why the color never updated.
|
||||||
|
var cp = new FrameworkElementFactory(typeof(ContentPresenter));
|
||||||
|
var btn = new Button
|
||||||
|
{
|
||||||
|
Content = sw, Cursor = Cursors.Hand, Focusable = false,
|
||||||
|
Background = Brushes.Transparent, BorderThickness = new Thickness(0), Padding = new Thickness(0),
|
||||||
|
Template = new ControlTemplate(typeof(Button)) { VisualTree = cp }
|
||||||
|
};
|
||||||
|
btn.Click += (_, _2) =>
|
||||||
|
{
|
||||||
|
var current = sw.Background is SolidColorBrush b ? b.Color : initial;
|
||||||
|
var dlg = new ColorPickerDialog(this, current);
|
||||||
|
dlg.ShowDialog();
|
||||||
|
// Apply SelectedColor unconditionally rather than gating on DialogResult: opening the picker
|
||||||
|
// as a nested dialog from this modal window + the fade-close makes ShowDialog return false even
|
||||||
|
// on OK. On Cancel, SelectedColor is still the original color, so this is harmless.
|
||||||
|
sw.Background = new SolidColorBrush(dlg.SelectedColor);
|
||||||
|
onPick(dlg.SelectedColor);
|
||||||
|
};
|
||||||
|
|
||||||
|
swatch = sw;
|
||||||
|
row.Children.Add(btn);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FrameworkElement Divider() => new Border { Height = 1, Background = R("CardBorderBrush"), Opacity = 0.6, Margin = new Thickness(0, 12, 0, 12) };
|
||||||
|
|
||||||
|
private void UpdateEnabledStates()
|
||||||
|
{
|
||||||
|
if (_wmTextPanel != null) _wmTextPanel.Visibility = _wmImageRadio.IsChecked == true ? Visibility.Collapsed : Visibility.Visible;
|
||||||
|
if (_wmImagePanel != null) _wmImagePanel.Visibility = _wmImageRadio.IsChecked == true ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
// Nothing to apply unless at least one section is enabled - EXCEPT when the document
|
||||||
|
// already has stamps: applying with both sections off is how they are removed (#145).
|
||||||
|
if (_applyBtn != null) _applyBtn.IsEnabled = _hadExisting
|
||||||
|
|| _numEnable?.IsChecked == true || _wmEnable?.IsChecked == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirroring only makes sense for a left/right position, so gray it out on a centered one.
|
||||||
|
private void UpdateMirrorEnabled()
|
||||||
|
{
|
||||||
|
if (_numMirror == null || _numPos == null) return;
|
||||||
|
_numMirror.IsEnabled = Positions[Math.Max(0, _numPos.SelectedIndex)].h != 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ChooseImage()
|
||||||
|
{
|
||||||
|
var ofd = new KillerPDF.Controls.FileDialog(KillerPDF.Controls.FileDialogMode.Open)
|
||||||
|
{ Filter = "Images|*.png;*.jpg;*.jpeg;*.bmp;*.gif|All files|*.*", ShowImagePreview = true };
|
||||||
|
if (ofd.ShowDialog(this) == true)
|
||||||
|
{
|
||||||
|
_wmImagePath = ofd.FileName;
|
||||||
|
_wmImageLabel.Text = System.IO.Path.GetFileName(_wmImagePath);
|
||||||
|
LoadWatermarkImage();
|
||||||
|
Schedule();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadWatermarkImage()
|
||||||
|
{
|
||||||
|
_wmImageSrc = null;
|
||||||
|
if (string.IsNullOrEmpty(_wmImagePath) || !System.IO.File.Exists(_wmImagePath)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bmp = new BitmapImage();
|
||||||
|
bmp.BeginInit();
|
||||||
|
bmp.CacheOption = BitmapCacheOption.OnLoad;
|
||||||
|
bmp.UriSource = new Uri(_wmImagePath!);
|
||||||
|
bmp.EndInit();
|
||||||
|
bmp.Freeze();
|
||||||
|
_wmImageSrc = bmp;
|
||||||
|
}
|
||||||
|
catch { _wmImageSrc = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- preview ----------
|
||||||
|
// ---------- Preview page stepper ----------
|
||||||
|
private FrameworkElement BuildPageNav()
|
||||||
|
{
|
||||||
|
_prevArrow = MakeNavArrow("", () => GoToPage(_pageIndex - 1)); // ChevronLeft
|
||||||
|
_nextArrow = MakeNavArrow("", () => GoToPage(_pageIndex + 1)); // ChevronRight
|
||||||
|
_pageNavLabel = new TextBlock
|
||||||
|
{
|
||||||
|
FontFamily = UiKit.UiFont, FontSize = 12,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(12, 0, 12, 0)
|
||||||
|
};
|
||||||
|
_pageNavLabel.SetResourceReference(TextBlock.ForegroundProperty, "TextBrush");
|
||||||
|
var row = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
Margin = new Thickness(0, 6, 0, 8)
|
||||||
|
};
|
||||||
|
row.Children.Add(_prevArrow);
|
||||||
|
row.Children.Add(_pageNavLabel);
|
||||||
|
row.Children.Add(_nextArrow);
|
||||||
|
UpdatePageNav();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same chrome as the print preview stepper (UiKit.Make), so the two windows share one button style.
|
||||||
|
private Button MakeNavArrow(string glyph, Action onClick)
|
||||||
|
{
|
||||||
|
var b = UiKit.Make(glyph, false);
|
||||||
|
b.FontFamily = UiKit.IconFont;
|
||||||
|
b.FontSize = 12;
|
||||||
|
b.Click += (_, _2) => onClick();
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button clicks step one page and render immediately.
|
||||||
|
private void GoToPage(int idx)
|
||||||
|
{
|
||||||
|
if (_pageProvider == null) return;
|
||||||
|
idx = Math.Max(0, Math.Min(_pageCount - 1, idx));
|
||||||
|
if (idx == _pageIndex) return;
|
||||||
|
_pageIndex = idx;
|
||||||
|
UpdatePageNav();
|
||||||
|
RenderCurrentPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wheel stepping advances the page number (and arrow states) instantly and defers the heavy page
|
||||||
|
// render until the wheel settles, so a fast flick scrolls quickly instead of blocking on each
|
||||||
|
// page's rasterization.
|
||||||
|
private void StepPage(int delta)
|
||||||
|
{
|
||||||
|
if (_pageProvider == null) return;
|
||||||
|
int idx = Math.Max(0, Math.Min(_pageCount - 1, _pageIndex + delta));
|
||||||
|
if (idx == _pageIndex) return;
|
||||||
|
_pageIndex = idx;
|
||||||
|
UpdatePageNav();
|
||||||
|
_navRenderTimer ??= MakeNavRenderTimer();
|
||||||
|
_navRenderTimer.Stop();
|
||||||
|
_navRenderTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private System.Windows.Threading.DispatcherTimer MakeNavRenderTimer()
|
||||||
|
{
|
||||||
|
var t = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromMilliseconds(90) };
|
||||||
|
t.Tick += (_, _2) => { t.Stop(); RenderCurrentPage(); };
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderCurrentPage()
|
||||||
|
{
|
||||||
|
if (_pageProvider == null) return;
|
||||||
|
var (src, wpt, hpt) = _pageProvider(_pageIndex);
|
||||||
|
if (src == null) return;
|
||||||
|
_pageSrc = src; _pageWpt = wpt; _pageHpt = hpt;
|
||||||
|
_preview.Source = src;
|
||||||
|
RenderPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePageNav()
|
||||||
|
{
|
||||||
|
if (_pageNavLabel == null) return;
|
||||||
|
_pageNavLabel.Text = string.Format(S("Str_PageOf"), _pageIndex + 1, _pageCount);
|
||||||
|
_prevArrow.IsEnabled = _pageIndex > 0;
|
||||||
|
_nextArrow.IsEnabled = _pageIndex < _pageCount - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SizePreviewImage()
|
||||||
|
{
|
||||||
|
if (_previewArea == null) return;
|
||||||
|
double availW = Math.Max(1, _previewArea.ActualWidth - 48);
|
||||||
|
double availH = Math.Max(1, _previewArea.ActualHeight - 48);
|
||||||
|
double ar = _pageHpt > 0 ? _pageWpt / _pageHpt : (_pageSrc.PixelWidth / (double)_pageSrc.PixelHeight);
|
||||||
|
double w = availW, h = w / ar;
|
||||||
|
if (h > availH) { h = availH; w = h * ar; }
|
||||||
|
_preview.Width = w;
|
||||||
|
_preview.Height = h;
|
||||||
|
_overlay.Width = w;
|
||||||
|
_overlay.Height = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HashSet<int> ParseRange(string range, int pageCount)
|
||||||
|
{
|
||||||
|
var set = new HashSet<int>();
|
||||||
|
if (string.IsNullOrWhiteSpace(range))
|
||||||
|
{
|
||||||
|
for (int i = 0; i < pageCount; i++) set.Add(i);
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
foreach (var part in range.Split(','))
|
||||||
|
{
|
||||||
|
var p = part.Trim();
|
||||||
|
if (p.Length == 0) continue;
|
||||||
|
int dash = p.IndexOf('-');
|
||||||
|
if (dash > 0)
|
||||||
|
{
|
||||||
|
if (int.TryParse(p[..dash].Trim(), out int a) && int.TryParse(p[(dash + 1)..].Trim(), out int b))
|
||||||
|
{
|
||||||
|
// Clamp the ends rather than testing each i: an unclamped "1-2147483647" wrapped
|
||||||
|
// i++ to int.MinValue at the top and never terminated. Same fix as ParseRange.
|
||||||
|
int lo = Math.Max(1, Math.Min(a, b)), hi = Math.Min(pageCount, Math.Max(a, b));
|
||||||
|
for (int i = lo; i <= hi; i++) set.Add(i - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (int.TryParse(p, out int single) && single >= 1 && single <= pageCount) set.Add(single - 1);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderPreview()
|
||||||
|
{
|
||||||
|
_overlay.Children.Clear();
|
||||||
|
_overlay.IsHitTestVisible = false; // re-enabled by MakeDraggable only when a custom stamp is shown
|
||||||
|
SizePreviewImage();
|
||||||
|
double pw = _preview.Width, ph = _preview.Height;
|
||||||
|
if (double.IsNaN(pw) || pw <= 0 || double.IsNaN(ph) || ph <= 0) return;
|
||||||
|
double pxPerPt = _pageHpt > 0 ? ph / _pageHpt : 1; // preview pixels per PDF point
|
||||||
|
double mx = pw * 0.05, my = ph * 0.04;
|
||||||
|
|
||||||
|
// Watermark sits under the page-number text (drawn first).
|
||||||
|
if (_wmEnable.IsChecked == true && ParseRange(_wmRange.Text, _pageCount).Contains(_pageIndex))
|
||||||
|
{
|
||||||
|
if (_wmImageRadio.IsChecked == true && _wmImageSrc != null)
|
||||||
|
{
|
||||||
|
double scale = _wmScale.Value / 100.0;
|
||||||
|
double iw = Math.Min(pw, _wmImageSrc.PixelWidth * pxPerPt * 0.5) * scale;
|
||||||
|
double ih = iw * _wmImageSrc.PixelHeight / Math.Max(1, _wmImageSrc.PixelWidth);
|
||||||
|
var img = new Image { Source = _wmImageSrc, Width = iw, Height = ih, Opacity = _wmOpacity.Value / 100.0, Stretch = Stretch.Fill };
|
||||||
|
PlaceRotated(img, iw, ih, _wmPos.SelectedIndex, pw, ph, mx, my, _wmAngle.Value);
|
||||||
|
}
|
||||||
|
else if (_wmImageRadio.IsChecked != true && _wmText.Text.Length > 0)
|
||||||
|
{
|
||||||
|
double fpx = ReadDouble(_wmSize, 64) * pxPerPt;
|
||||||
|
var tb = new TextBlock { Text = _wmText.Text, FontFamily = new FontFamily(_wmFont.SelectedItem as string ?? "Segoe UI"), FontWeight = FontWeights.Bold, FontSize = Math.Max(6, fpx), Foreground = new SolidColorBrush(_wmColor), Opacity = _wmOpacity.Value / 100.0 };
|
||||||
|
var sz = Measure(tb);
|
||||||
|
PlaceRotated(tb, sz.Width, sz.Height, _wmPos.SelectedIndex, pw, ph, mx, my, _wmAngle.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page number for the current page.
|
||||||
|
if (_numEnable.IsChecked == true && ParseRange(_numRange.Text, _pageCount).Contains(_pageIndex))
|
||||||
|
{
|
||||||
|
double fpx = ReadDouble(_numSize, 12) * pxPerPt;
|
||||||
|
string text = (_numFormat.Text.Length == 0 ? "{n}" : _numFormat.Text)
|
||||||
|
.Replace("{n}", (ReadInt(_numStart, 1) + _pageIndex).ToString())
|
||||||
|
.Replace("{N}", _pageCount.ToString());
|
||||||
|
if (text.Length > 0)
|
||||||
|
{
|
||||||
|
var tb = new TextBlock { Text = text, FontFamily = UiKit.UiFont, FontSize = Math.Max(5, fpx), Foreground = new SolidColorBrush(_numColor) };
|
||||||
|
var sz = Measure(tb);
|
||||||
|
int h = Positions[Math.Max(0, _numPos.SelectedIndex)].h, v = Positions[Math.Max(0, _numPos.SelectedIndex)].v;
|
||||||
|
double x, y;
|
||||||
|
if (h < 0) // custom: drag the number anywhere on the page
|
||||||
|
{
|
||||||
|
bool mirroredHere = _numMirror.IsChecked == true && (_pageIndex % 2 == 1);
|
||||||
|
double cx = mirroredHere ? 1 - _spec.NumCustomX : _spec.NumCustomX;
|
||||||
|
x = cx * pw - sz.Width / 2;
|
||||||
|
y = _spec.NumCustomY * ph - sz.Height / 2;
|
||||||
|
MakeDraggable(tb, sz.Width, sz.Height, pw, ph, (fx, fy) =>
|
||||||
|
{
|
||||||
|
_spec.NumCustomX = mirroredHere ? 1 - fx : fx;
|
||||||
|
_spec.NumCustomY = fy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (_numMirror.IsChecked == true && h != 1 && (_pageIndex % 2 == 1)) h = 2 - h;
|
||||||
|
x = h == 0 ? mx : h == 2 ? pw - sz.Width - mx : (pw - sz.Width) / 2;
|
||||||
|
y = v == 0 ? my : v == 1 ? (ph - sz.Height) / 2 : ph - sz.Height - my;
|
||||||
|
}
|
||||||
|
Canvas.SetLeft(tb, x);
|
||||||
|
Canvas.SetTop(tb, y);
|
||||||
|
_overlay.Children.Add(tb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PlaceRotated(FrameworkElement el, double w, double h, int posIndex, double pw, double ph, double mx, double my, double angle)
|
||||||
|
{
|
||||||
|
int hpos = Positions[Math.Max(0, posIndex)].h, vpos = Positions[Math.Max(0, posIndex)].v;
|
||||||
|
el.RenderTransformOrigin = new Point(0.5, 0.5);
|
||||||
|
el.RenderTransform = new RotateTransform(-angle);
|
||||||
|
double x, y;
|
||||||
|
if (hpos < 0) // custom: drag the watermark anywhere on the page
|
||||||
|
{
|
||||||
|
x = _spec.WmCustomX * pw - w / 2;
|
||||||
|
y = _spec.WmCustomY * ph - h / 2;
|
||||||
|
MakeDraggable(el, w, h, pw, ph, (fx, fy) => { _spec.WmCustomX = fx; _spec.WmCustomY = fy; });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
x = hpos == 0 ? mx : hpos == 2 ? pw - w - mx : (pw - w) / 2;
|
||||||
|
y = vpos == 0 ? my : vpos == 1 ? (ph - h) / 2 : ph - h - my;
|
||||||
|
}
|
||||||
|
Canvas.SetLeft(el, x);
|
||||||
|
Canvas.SetTop(el, y);
|
||||||
|
_overlay.Children.Add(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Makes a stamp element draggable in the preview; reports the new center as fractions of the page.
|
||||||
|
private void MakeDraggable(FrameworkElement el, double elW, double elH, double pw, double ph, Action<double, double> onMove)
|
||||||
|
{
|
||||||
|
_overlay.IsHitTestVisible = true;
|
||||||
|
el.IsHitTestVisible = true;
|
||||||
|
el.Cursor = Cursors.SizeAll;
|
||||||
|
bool dragging = false;
|
||||||
|
el.MouseLeftButtonDown += (_, e) => { dragging = true; el.CaptureMouse(); e.Handled = true; };
|
||||||
|
el.MouseLeftButtonUp += (_, _2) => { dragging = false; el.ReleaseMouseCapture(); };
|
||||||
|
el.MouseMove += (_, e) =>
|
||||||
|
{
|
||||||
|
if (!dragging) return;
|
||||||
|
var p = e.GetPosition(_overlay);
|
||||||
|
double left = Math.Max(0, Math.Min(pw - elW, p.X - elW / 2));
|
||||||
|
double top = Math.Max(0, Math.Min(ph - elH, p.Y - elH / 2));
|
||||||
|
Canvas.SetLeft(el, left);
|
||||||
|
Canvas.SetTop(el, top);
|
||||||
|
double fx = pw > 0 ? Math.Max(0, Math.Min(1, (left + elW / 2) / pw)) : 0.5;
|
||||||
|
double fy = ph > 0 ? Math.Max(0, Math.Min(1, (top + elH / 2) / ph)) : 0.5;
|
||||||
|
onMove(fx, fy);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Size Measure(FrameworkElement el)
|
||||||
|
{
|
||||||
|
el.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
|
||||||
|
return el.DesiredSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double ReadDouble(TextBox tb, double fallback)
|
||||||
|
=> double.TryParse(tb.Text?.Trim(), NumberStyles.Any, CultureInfo.CurrentCulture, out double d) && d > 0 ? d : fallback;
|
||||||
|
private static int ReadInt(TextBox tb, int fallback)
|
||||||
|
=> int.TryParse(tb.Text?.Trim(), out int i) ? i : fallback;
|
||||||
|
|
||||||
|
private void ResetAll()
|
||||||
|
{
|
||||||
|
var d = new StampSpec { NumbersEnabled = _numEnable.IsChecked == true };
|
||||||
|
_numEnable.IsChecked = d.NumbersEnabled;
|
||||||
|
_numStart.Text = d.StartNumber.ToString();
|
||||||
|
_numFormat.Text = d.Format;
|
||||||
|
_numSize.Text = d.NumFontPt.ToString("0");
|
||||||
|
_numRange.Text = d.NumRange;
|
||||||
|
_numColor = d.NumColor; _numSwatch.Background = new SolidColorBrush(d.NumColor);
|
||||||
|
_wmText.Text = d.WmText;
|
||||||
|
_wmSize.Text = d.WmFontPt.ToString("0");
|
||||||
|
_wmFont.SelectedItem = d.WmFont;
|
||||||
|
_wmColor = d.WmColor; _wmSwatch.Background = new SolidColorBrush(d.WmColor);
|
||||||
|
_wmAngle.Value = d.WmAngle;
|
||||||
|
_wmOpacity.Value = d.WmOpacity * 100;
|
||||||
|
_wmScale.Value = d.WmScale * 100;
|
||||||
|
Schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CommitAndClose()
|
||||||
|
{
|
||||||
|
_spec.NumbersEnabled = _numEnable.IsChecked == true;
|
||||||
|
_spec.StartNumber = ReadInt(_numStart, 1);
|
||||||
|
_spec.Format = _numFormat.Text.Length == 0 ? "{n}" : _numFormat.Text;
|
||||||
|
_spec.NumFontPt = ReadDouble(_numSize, 12);
|
||||||
|
_spec.NumColor = _numColor;
|
||||||
|
_spec.NumRange = _numRange.Text.Trim();
|
||||||
|
_spec.NumMirror = _numMirror.IsChecked == true;
|
||||||
|
(_spec.NumPosH, _spec.NumPosV) = (Positions[Math.Max(0, _numPos.SelectedIndex)].h, Positions[Math.Max(0, _numPos.SelectedIndex)].v);
|
||||||
|
|
||||||
|
_spec.WmEnabled = _wmEnable.IsChecked == true;
|
||||||
|
_spec.WmIsImage = _wmImageRadio.IsChecked == true;
|
||||||
|
_spec.WmText = _wmText.Text;
|
||||||
|
_spec.WmFontPt = ReadDouble(_wmSize, 64);
|
||||||
|
_spec.WmFont = _wmFont.SelectedItem as string ?? "Segoe UI";
|
||||||
|
_spec.WmColor = _wmColor;
|
||||||
|
_spec.WmOpacity = _wmOpacity.Value / 100.0;
|
||||||
|
_spec.WmAngle = _wmAngle.Value;
|
||||||
|
_spec.WmScale = _wmScale.Value / 100.0;
|
||||||
|
_spec.WmImagePath = _wmImagePath;
|
||||||
|
_spec.WmRange = _wmRange.Text.Trim();
|
||||||
|
(_spec.WmPosH, _spec.WmPosV) = (Positions[Math.Max(0, _wmPos.SelectedIndex)].h, Positions[Math.Max(0, _wmPos.SelectedIndex)].v);
|
||||||
|
|
||||||
|
Result = _spec;
|
||||||
|
Applied = true;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Data;
|
||||||
|
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
// Keeps the document scrollbar reactive (thumb sized to the visible proportion) while
|
||||||
|
// guaranteeing it never shrinks below a grabbable floor.
|
||||||
|
//
|
||||||
|
// WPF's Track sizes the thumb AND the repeat buttons from the raw proportional value
|
||||||
|
// (trackLen * viewport / (range + viewport)). Thumb.MinHeight does NOT feed that math - it
|
||||||
|
// only stretches the thumb's render, so on a long document the thumb overflows its tiny
|
||||||
|
// proportional slot and the increase RepeatButton paints over the overflow (the "8px"
|
||||||
|
// scrollbar). Enforcing the minimum here, by raising the ViewportSize the Track sees, makes
|
||||||
|
// the Track size the thumb and the buttons from the same floored value - no overflow, no
|
||||||
|
// overlap, still proportional whenever there is room.
|
||||||
|
//
|
||||||
|
// Bindings (in order): ViewportSize, Maximum, Minimum, ActualWidth, ActualHeight, Orientation.
|
||||||
|
// ConverterParameter: the floor in pixels (default 64).
|
||||||
|
public sealed class ThumbViewportFloorConverter : IMultiValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
double vp = AsDouble(values, 0);
|
||||||
|
// Anything unexpected -> hand back the real ViewportSize so behavior is unchanged.
|
||||||
|
if (double.IsNaN(vp) || vp <= 0) return vp;
|
||||||
|
|
||||||
|
double max = AsDouble(values, 1);
|
||||||
|
double min = AsDouble(values, 2);
|
||||||
|
double width = AsDouble(values, 3);
|
||||||
|
double height = AsDouble(values, 4);
|
||||||
|
bool vertical = values.Length <= 5 || values[5] is not Orientation o
|
||||||
|
|| o == Orientation.Vertical;
|
||||||
|
|
||||||
|
double trackLen = vertical ? height : width;
|
||||||
|
double range = max - min;
|
||||||
|
if (double.IsNaN(trackLen) || trackLen <= 0 || range <= 0) return vp;
|
||||||
|
|
||||||
|
double floor = 64;
|
||||||
|
if (parameter != null &&
|
||||||
|
double.TryParse(parameter.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var p) && p > 0)
|
||||||
|
floor = p;
|
||||||
|
|
||||||
|
// Never let the floor eat the whole track; leave room for the thumb to travel.
|
||||||
|
floor = Math.Min(floor, trackLen * 0.5);
|
||||||
|
if (trackLen <= floor) return vp;
|
||||||
|
|
||||||
|
// ViewportSize that yields a thumb exactly = floor; take the larger of it and the real VP.
|
||||||
|
double vpForFloor = floor * range / (trackLen - floor);
|
||||||
|
return Math.Max(vp, vpForFloor);
|
||||||
|
}
|
||||||
|
|
||||||
|
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
private static double AsDouble(object[] values, int i)
|
||||||
|
{
|
||||||
|
if (values == null || i >= values.Length) return double.NaN;
|
||||||
|
object v = values[i];
|
||||||
|
if (v == null || v == DependencyProperty.UnsetValue) return double.NaN;
|
||||||
|
try { return System.Convert.ToDouble(v, CultureInfo.InvariantCulture); }
|
||||||
|
catch { return double.NaN; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,681 @@
|
|||||||
|
using System;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using System.Windows.Shapes;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using KillerPDF.Services;
|
||||||
|
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Modal "Transform" window. Renders the current page on its own canvas (so the main view's mode is
|
||||||
|
/// irrelevant) and lets the user rotate (quarter turns + fine deskew) and scale it, with the controls in
|
||||||
|
/// a right-hand sidebar (the mirror of Print Preview). Apply hands the chosen angle / scale / page-mode
|
||||||
|
/// back to the caller, which rasterizes at full resolution. Draggable corner handles are the next step.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class TransformWindow : Window
|
||||||
|
{
|
||||||
|
public bool Applied { get; private set; }
|
||||||
|
public double Angle { get; private set; } // total = quarter turns + fine
|
||||||
|
public double Scale { get; private set; } = 1.0;
|
||||||
|
public bool FixedPage { get; private set; } // true = keep page size (margins); false = resize page
|
||||||
|
public bool FlipH { get; private set; }
|
||||||
|
public bool FlipV { get; private set; }
|
||||||
|
// #174: source levels (black point, white point, midtone gamma). 0/255/1.0 = untouched.
|
||||||
|
public int LevelBlack { get; private set; }
|
||||||
|
public int LevelWhite { get; private set; } = 255;
|
||||||
|
public double LevelGamma { get; private set; } = 1.0;
|
||||||
|
|
||||||
|
public Point[] PerspectiveCorners { get; private set; } =
|
||||||
|
[new(0, 0), new(1, 0), new(1, 1), new(0, 1)];
|
||||||
|
|
||||||
|
private readonly BitmapSource _src;
|
||||||
|
private readonly Image _preview = new()
|
||||||
|
{
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Margin = new Thickness(24),
|
||||||
|
Effect = new System.Windows.Media.Effects.DropShadowEffect
|
||||||
|
{ Color = Colors.Black, BlurRadius = 14, ShadowDepth = 3, Direction = 270, Opacity = 0.45 }
|
||||||
|
};
|
||||||
|
private readonly Border _previewArea = null!;
|
||||||
|
private readonly double _srcW;
|
||||||
|
private readonly double _srcH;
|
||||||
|
private readonly double _pageWpt;
|
||||||
|
private readonly double _pageHpt;
|
||||||
|
private readonly TextBlock _sizeReadout = null!;
|
||||||
|
private int _quarter; // 0..3 quarter turns clockwise
|
||||||
|
private double _fine; // fine deskew, degrees
|
||||||
|
private double _scale = 1.0;
|
||||||
|
private bool _fixedPage;
|
||||||
|
private readonly TextBlock _rotReadout = null!;
|
||||||
|
private readonly TextBlock _scaleReadout = null!;
|
||||||
|
private readonly Slider _rotSlider = null!;
|
||||||
|
private readonly Slider _scaleSlider = null!;
|
||||||
|
private Slider _lvlBlack = null!, _lvlWhite = null!, _lvlGamma = null!; // #174
|
||||||
|
private readonly RadioButton _resizeRadio = null!;
|
||||||
|
private bool _flipH;
|
||||||
|
private bool _flipV;
|
||||||
|
private readonly CheckBox _flipHCheck = null!;
|
||||||
|
private readonly CheckBox _flipVCheck = null!;
|
||||||
|
private readonly Canvas _lineCanvas = null!;
|
||||||
|
private readonly Line _alignLine = null!;
|
||||||
|
private readonly CheckBox _deskewCheck = null!;
|
||||||
|
private readonly TextBlock _lineCoords = null!;
|
||||||
|
private bool _drawingLine;
|
||||||
|
private Point _lineStart;
|
||||||
|
private Point _startPagePt;
|
||||||
|
private readonly DispatcherTimer _previewTimer = null!;
|
||||||
|
private readonly Canvas _perspectiveCanvas = null!;
|
||||||
|
private readonly Polygon _perspectiveOutline = null!;
|
||||||
|
private readonly Ellipse[] _perspectiveHandles = new Ellipse[4];
|
||||||
|
private readonly CheckBox _perspectiveCheck = null!;
|
||||||
|
private int _dragPerspective = -1;
|
||||||
|
|
||||||
|
private static SolidColorBrush R(string key) => (SolidColorBrush)Application.Current.Resources[key];
|
||||||
|
private static string S(string key) => Application.Current.TryFindResource(key) as string ?? key;
|
||||||
|
|
||||||
|
public TransformWindow(Window owner, BitmapSource src, double pageWpt, double pageHpt)
|
||||||
|
{
|
||||||
|
_src = src;
|
||||||
|
_srcW = src.PixelWidth;
|
||||||
|
_srcH = src.PixelHeight;
|
||||||
|
_pageWpt = pageWpt;
|
||||||
|
_pageHpt = pageHpt;
|
||||||
|
Title = "KillerPDF - " + S("Str_Tf_Suffix");
|
||||||
|
Width = 980;
|
||||||
|
Height = 720;
|
||||||
|
MinWidth = 640;
|
||||||
|
MinHeight = 460;
|
||||||
|
DialogChrome.Configure(this, owner, resizable: true);
|
||||||
|
|
||||||
|
var darkSlider = owner?.TryFindResource("DarkSlider") as Style;
|
||||||
|
var themeRadio = owner?.TryFindResource("ThemeRadio") as Style;
|
||||||
|
|
||||||
|
// Coalesce rapid slider changes: the heavy compose (especially scaling a page up, which makes a
|
||||||
|
// big bitmap) only runs ~25x/sec on the latest value, so dragging stays smooth instead of queuing
|
||||||
|
// a backlog of full re-renders.
|
||||||
|
_previewTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(40) };
|
||||||
|
_previewTimer.Tick += (_, _2) => { _previewTimer.Stop(); UpdatePreview(); };
|
||||||
|
|
||||||
|
var root = new DockPanel();
|
||||||
|
|
||||||
|
// ---- Right sidebar (transparent so it blends with the dark title bar, like Print Preview) ----
|
||||||
|
var sidebar = new Border { Width = 288, Background = Brushes.Transparent, Padding = new Thickness(16, 8, 16, 14) };
|
||||||
|
DockPanel.SetDock(sidebar, Dock.Right);
|
||||||
|
|
||||||
|
var side = new DockPanel();
|
||||||
|
|
||||||
|
// Bottom: a "Reset all" text link on its own line (translations like "Tout reinitialiser" are
|
||||||
|
// long), with Cancel / Apply right-aligned beneath it - so nothing crowds or clips.
|
||||||
|
var bottom = new StackPanel { Margin = new Thickness(0, 10, 0, 0) };
|
||||||
|
var resetAll = new TextBlock
|
||||||
|
{
|
||||||
|
Text = S("Str_Tf_ResetAll"), FontFamily = UiKit.UiFont, FontSize = 12,
|
||||||
|
Foreground = R("MutedTextBrush"), Cursor = Cursors.Hand,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Left
|
||||||
|
};
|
||||||
|
resetAll.MouseEnter += (_, _2) => resetAll.Foreground = R("PrimaryBrush");
|
||||||
|
resetAll.MouseLeave += (_, _2) => resetAll.Foreground = R("MutedTextBrush");
|
||||||
|
resetAll.MouseLeftButtonUp += (_, _2) =>
|
||||||
|
{
|
||||||
|
_quarter = 0; _rotSlider.Value = 0; _scaleSlider.Value = 100;
|
||||||
|
_resizeRadio.IsChecked = true; _flipHCheck.IsChecked = false; _flipVCheck.IsChecked = false;
|
||||||
|
ResetPerspective();
|
||||||
|
ResetLevels(); // #174
|
||||||
|
};
|
||||||
|
bottom.Children.Add(resetAll);
|
||||||
|
var actionRow = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 8, 0, 0) };
|
||||||
|
var cancelBtn = UiKit.Make(S("Str_Tf_Cancel"), false);
|
||||||
|
cancelBtn.Margin = new Thickness(0, 0, 8, 0);
|
||||||
|
cancelBtn.Click += (_, _2) => { Applied = false; Close(); };
|
||||||
|
cancelBtn.IsCancel = true; // Esc
|
||||||
|
actionRow.Children.Add(cancelBtn);
|
||||||
|
var applyBtn = UiKit.Make(S("Str_Tf_Apply"), true);
|
||||||
|
applyBtn.Click += (_, _2) => CommitAndClose();
|
||||||
|
applyBtn.IsDefault = true; // Enter
|
||||||
|
actionRow.Children.Add(applyBtn);
|
||||||
|
bottom.Children.Add(actionRow);
|
||||||
|
DockPanel.SetDock(bottom, Dock.Bottom);
|
||||||
|
side.Children.Add(bottom);
|
||||||
|
|
||||||
|
var stack = new StackPanel();
|
||||||
|
|
||||||
|
int rotateStart = stack.Children.Count;
|
||||||
|
// Quarter-turn buttons.
|
||||||
|
var turnRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 2, 0, 6) };
|
||||||
|
var turnL = UiKit.Make("↺ 90°", false);
|
||||||
|
turnL.Margin = new Thickness(0, 0, 6, 0);
|
||||||
|
turnL.Click += (_, _2) => { _quarter = (_quarter + 3) % 4; UpdatePreview(); };
|
||||||
|
var turnR = UiKit.Make("90° ↻", false);
|
||||||
|
turnR.Click += (_, _2) => { _quarter = (_quarter + 1) % 4; UpdatePreview(); };
|
||||||
|
turnRow.Children.Add(turnL);
|
||||||
|
turnRow.Children.Add(turnR);
|
||||||
|
stack.Children.Add(turnRow);
|
||||||
|
|
||||||
|
_rotSlider = new Slider { Minimum = -45, Maximum = 45, Value = 0, TickFrequency = 1, SmallChange = 0.1, LargeChange = 1, Margin = new Thickness(0, 2, 0, 2) };
|
||||||
|
if (darkSlider != null) _rotSlider.Style = darkSlider;
|
||||||
|
_rotSlider.ValueChanged += (_, ev) => { _fine = Math.Round(ev.NewValue, 1); if (_rotReadout != null) _rotReadout.Text = $"{Total:0.0}°"; SchedulePreview(); };
|
||||||
|
stack.Children.Add(_rotSlider);
|
||||||
|
stack.Children.Add(ValueRow(S("Str_Tf_Angle"), "0.0°", out _rotReadout, out var rotReset));
|
||||||
|
rotReset.Click += (_, _2) => { _quarter = 0; _rotSlider.Value = 0; UpdatePreview(); };
|
||||||
|
|
||||||
|
WrapSection(stack, rotateStart, S("Str_Tf_Rotate"), expanded: true);
|
||||||
|
stack.Children.Add(Divider());
|
||||||
|
|
||||||
|
int scaleStart = stack.Children.Count;
|
||||||
|
_scaleSlider = new Slider { Minimum = 25, Maximum = 200, Value = 100, TickFrequency = 5, SmallChange = 1, LargeChange = 10, Margin = new Thickness(0, 2, 0, 2) };
|
||||||
|
if (darkSlider != null) _scaleSlider.Style = darkSlider;
|
||||||
|
_scaleSlider.ValueChanged += (_, ev) => { _scale = Math.Round(ev.NewValue) / 100.0; _scaleReadout.Text = $"{ev.NewValue:0}%"; SchedulePreview(); };
|
||||||
|
stack.Children.Add(_scaleSlider);
|
||||||
|
stack.Children.Add(ValueRow(S("Str_Tf_Size"), "100%", out _scaleReadout, out var scaleReset));
|
||||||
|
scaleReset.Click += (_, _2) => _scaleSlider.Value = 100;
|
||||||
|
|
||||||
|
stack.Children.Add(new TextBlock { Text = S("Str_Tf_WhenScaling"), Foreground = R("MutedTextBrush"), FontFamily = UiKit.UiFont, FontSize = 11, Margin = new Thickness(0, 10, 0, 4) });
|
||||||
|
_resizeRadio = MakeRadio(S("Str_Tf_ResizePage"), true, themeRadio);
|
||||||
|
var fixedRadio = MakeRadio(S("Str_Tf_KeepSize"), false, themeRadio);
|
||||||
|
_resizeRadio.Checked += (_, _2) => { _fixedPage = false; UpdatePreview(); };
|
||||||
|
fixedRadio.Checked += (_, _2) => { _fixedPage = true; UpdatePreview(); };
|
||||||
|
stack.Children.Add(_resizeRadio);
|
||||||
|
stack.Children.Add(fixedRadio);
|
||||||
|
|
||||||
|
// Live output dimensions, so scale changes (including above 100%, where the preview clamps to
|
||||||
|
// fit) are always legible as a number even when the page can't grow on screen.
|
||||||
|
_sizeReadout = new TextBlock { Foreground = R("MutedTextBrush"), FontFamily = UiKit.MonoFont, FontSize = 11, Margin = new Thickness(0, 8, 0, 0) };
|
||||||
|
stack.Children.Add(_sizeReadout);
|
||||||
|
|
||||||
|
WrapSection(stack, scaleStart, S("Str_Tf_Scale"), expanded: false);
|
||||||
|
stack.Children.Add(Divider());
|
||||||
|
int flipStart = stack.Children.Count;
|
||||||
|
_flipHCheck = MakeCheck(S("Str_Tf_FlipH"));
|
||||||
|
_flipHCheck.Checked += (_, _2) => { _flipH = true; UpdatePreview(); };
|
||||||
|
_flipHCheck.Unchecked += (_, _2) => { _flipH = false; UpdatePreview(); };
|
||||||
|
stack.Children.Add(_flipHCheck);
|
||||||
|
_flipVCheck = MakeCheck(S("Str_Tf_FlipV"));
|
||||||
|
_flipVCheck.Checked += (_, _2) => { _flipV = true; UpdatePreview(); };
|
||||||
|
_flipVCheck.Unchecked += (_, _2) => { _flipV = false; UpdatePreview(); };
|
||||||
|
stack.Children.Add(_flipVCheck);
|
||||||
|
|
||||||
|
WrapSection(stack, flipStart, S("Str_Tf_Flip"), expanded: false);
|
||||||
|
stack.Children.Add(Divider());
|
||||||
|
int skewStart = stack.Children.Count;
|
||||||
|
_deskewCheck = MakeCheck(S("Str_Tf_LevelLine"));
|
||||||
|
_deskewCheck.Checked += (_, _2) => { _lineCanvas.IsHitTestVisible = true; };
|
||||||
|
_deskewCheck.Unchecked += (_, _2) => { _lineCanvas.IsHitTestVisible = false; _alignLine.Visibility = Visibility.Collapsed; _lineCoords.Text = ""; };
|
||||||
|
stack.Children.Add(_deskewCheck);
|
||||||
|
stack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = S("Str_Tf_SkewHint"),
|
||||||
|
Foreground = R("MutedTextBrush"), FontFamily = UiKit.UiFont, FontSize = 10,
|
||||||
|
TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 4, 0, 0)
|
||||||
|
});
|
||||||
|
// Live cursor coordinates (page points), so the user can place the line precisely on the small
|
||||||
|
// preview. Start point on press, end point as they drag.
|
||||||
|
_lineCoords = new TextBlock
|
||||||
|
{
|
||||||
|
Text = "", Foreground = R("MutedTextBrush"), FontFamily = UiKit.MonoFont,
|
||||||
|
FontSize = 11, LineHeight = 16, Margin = new Thickness(0, 6, 0, 0), Padding = new Thickness(3)
|
||||||
|
};
|
||||||
|
stack.Children.Add(_lineCoords);
|
||||||
|
|
||||||
|
WrapSection(stack, skewStart, S("Str_Tf_Skew"), expanded: false);
|
||||||
|
stack.Children.Add(Divider());
|
||||||
|
int perspectiveStart = stack.Children.Count;
|
||||||
|
_perspectiveCheck = MakeCheck(S("Str_Tf_CorrectPerspective"));
|
||||||
|
_perspectiveCheck.Checked += (_, _2) =>
|
||||||
|
{
|
||||||
|
_deskewCheck.IsChecked = false;
|
||||||
|
_perspectiveCanvas.Visibility = Visibility.Visible;
|
||||||
|
_perspectiveCanvas.IsHitTestVisible = true;
|
||||||
|
UpdatePerspectiveOverlay();
|
||||||
|
};
|
||||||
|
_perspectiveCheck.Unchecked += (_, _2) =>
|
||||||
|
{
|
||||||
|
_perspectiveCanvas.Visibility = Visibility.Collapsed;
|
||||||
|
_perspectiveCanvas.IsHitTestVisible = false;
|
||||||
|
};
|
||||||
|
stack.Children.Add(_perspectiveCheck);
|
||||||
|
stack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = S("Str_Tf_PerspectiveHint"),
|
||||||
|
Foreground = R("MutedTextBrush"), FontFamily = UiKit.UiFont, FontSize = 10,
|
||||||
|
TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 4, 0, 0)
|
||||||
|
});
|
||||||
|
var resetPerspective = UiKit.Make(S("Str_Tf_ResetCorners"), false);
|
||||||
|
resetPerspective.Margin = new Thickness(0, 7, 0, 0);
|
||||||
|
resetPerspective.HorizontalAlignment = HorizontalAlignment.Left;
|
||||||
|
resetPerspective.Click += (_, _2) => ResetPerspective();
|
||||||
|
stack.Children.Add(resetPerspective);
|
||||||
|
WrapSection(stack, perspectiveStart, S("Str_Tf_Perspective"), expanded: false);
|
||||||
|
|
||||||
|
// #174: LEVELS - FineReader-style source levels for rescuing pale scans. Black point,
|
||||||
|
// white point, and a midtone gamma; live in the preview, baked on Apply like every
|
||||||
|
// other correction here.
|
||||||
|
stack.Children.Add(Divider());
|
||||||
|
int levelsStart = stack.Children.Count;
|
||||||
|
stack.Children.Add(SliderLabel(S("Str_Tf_LevelsBlack")));
|
||||||
|
_lvlBlack = new Slider { Minimum = 0, Maximum = 200, Value = 0, TickFrequency = 5, SmallChange = 1, LargeChange = 10, Margin = new Thickness(0, 2, 0, 2) };
|
||||||
|
if (darkSlider != null) _lvlBlack.Style = darkSlider;
|
||||||
|
_lvlBlack.ValueChanged += (_, ev) => { LevelBlack = (int)Math.Round(ev.NewValue); SchedulePreview(); };
|
||||||
|
stack.Children.Add(_lvlBlack);
|
||||||
|
stack.Children.Add(SliderLabel(S("Str_Tf_LevelsWhite")));
|
||||||
|
_lvlWhite = new Slider { Minimum = 55, Maximum = 255, Value = 255, TickFrequency = 5, SmallChange = 1, LargeChange = 10, Margin = new Thickness(0, 2, 0, 2) };
|
||||||
|
if (darkSlider != null) _lvlWhite.Style = darkSlider;
|
||||||
|
_lvlWhite.ValueChanged += (_, ev) => { LevelWhite = (int)Math.Round(ev.NewValue); SchedulePreview(); };
|
||||||
|
stack.Children.Add(_lvlWhite);
|
||||||
|
stack.Children.Add(SliderLabel(S("Str_Tf_LevelsGamma")));
|
||||||
|
_lvlGamma = new Slider { Minimum = 0.2, Maximum = 2.5, Value = 1.0, TickFrequency = 0.05, SmallChange = 0.05, LargeChange = 0.2, Margin = new Thickness(0, 2, 0, 2) };
|
||||||
|
if (darkSlider != null) _lvlGamma.Style = darkSlider;
|
||||||
|
_lvlGamma.ValueChanged += (_, ev) => { LevelGamma = Math.Round(ev.NewValue, 2); SchedulePreview(); };
|
||||||
|
stack.Children.Add(_lvlGamma);
|
||||||
|
var levelsReset = UiKit.Make(S("Str_Tf_Reset"), false);
|
||||||
|
levelsReset.Margin = new Thickness(0, 7, 0, 0);
|
||||||
|
levelsReset.HorizontalAlignment = HorizontalAlignment.Left;
|
||||||
|
levelsReset.Click += (_, _2) => ResetLevels();
|
||||||
|
stack.Children.Add(levelsReset);
|
||||||
|
WrapSection(stack, levelsStart, S("Str_Tf_Levels"), expanded: false);
|
||||||
|
|
||||||
|
side.Children.Add(new ScrollViewer
|
||||||
|
{
|
||||||
|
Content = stack,
|
||||||
|
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||||||
|
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
|
||||||
|
});
|
||||||
|
sidebar.Child = side;
|
||||||
|
root.Children.Add(sidebar);
|
||||||
|
|
||||||
|
// ---- Preview area: a documentbg box (1px frame, margin, rounded) with grain in the margins and
|
||||||
|
// the page (sized to its true relative scale, with a drop shadow) centered on top. ----
|
||||||
|
var previewWrap = new Border
|
||||||
|
{
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = UiKit.RadControl,
|
||||||
|
Margin = new Thickness(8, 4, 8, 12),
|
||||||
|
ClipToBounds = true
|
||||||
|
};
|
||||||
|
previewWrap.SetResourceReference(Border.BackgroundProperty, "BgCanvas");
|
||||||
|
previewWrap.SetResourceReference(Border.BorderBrushProperty, "PaneBorderBrush");
|
||||||
|
|
||||||
|
var previewGrid = new Grid();
|
||||||
|
var pgGrain = (owner as MainWindow)?.GrainTexture;
|
||||||
|
if (pgGrain != null)
|
||||||
|
{
|
||||||
|
double pop = Application.Current.Resources["GrainOpacity"] is double pgo ? pgo : 0.05;
|
||||||
|
previewGrid.Children.Add(new Border
|
||||||
|
{
|
||||||
|
IsHitTestVisible = false, Opacity = pop,
|
||||||
|
Background = new ImageBrush(pgGrain) { TileMode = TileMode.Tile, ViewportUnits = BrushMappingMode.Absolute, Viewport = new Rect(0, 0, 256, 256), Stretch = Stretch.None }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
RenderOptions.SetBitmapScalingMode(_preview, BitmapScalingMode.HighQuality);
|
||||||
|
_preview.Source = _src;
|
||||||
|
previewGrid.Children.Add(_preview);
|
||||||
|
|
||||||
|
// Alignment-line overlay: when "Draw a level line" is on, the user drags a reference line across
|
||||||
|
// the page and the page rotates so that line becomes level. Hit-testing is off until enabled, so
|
||||||
|
// it never interferes with the rest of the preview.
|
||||||
|
_lineCanvas = new Canvas { Background = Brushes.Transparent, IsHitTestVisible = false, Cursor = Cursors.Cross };
|
||||||
|
_alignLine = new Line
|
||||||
|
{
|
||||||
|
Stroke = R("PrimaryBrush"), StrokeThickness = 2, StrokeDashArray = [4, 3],
|
||||||
|
Visibility = Visibility.Collapsed,
|
||||||
|
Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.White, BlurRadius = 3, ShadowDepth = 0, Opacity = 0.8 }
|
||||||
|
};
|
||||||
|
_lineCanvas.Children.Add(_alignLine);
|
||||||
|
_lineCanvas.MouseLeftButtonDown += LineCanvas_Down;
|
||||||
|
_lineCanvas.MouseMove += LineCanvas_Move;
|
||||||
|
_lineCanvas.MouseLeftButtonUp += LineCanvas_Up;
|
||||||
|
previewGrid.Children.Add(_lineCanvas);
|
||||||
|
|
||||||
|
_perspectiveCanvas = new Canvas
|
||||||
|
{
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
Visibility = Visibility.Collapsed,
|
||||||
|
IsHitTestVisible = false,
|
||||||
|
Cursor = Cursors.Cross,
|
||||||
|
};
|
||||||
|
_perspectiveOutline = new Polygon
|
||||||
|
{
|
||||||
|
Stroke = R("PrimaryBrush"), StrokeThickness = 2, StrokeDashArray = [5, 3],
|
||||||
|
Fill = new SolidColorBrush(Color.FromArgb(24, 30, 165, 76)), IsHitTestVisible = false,
|
||||||
|
};
|
||||||
|
_perspectiveCanvas.Children.Add(_perspectiveOutline);
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
{
|
||||||
|
var handle = new Ellipse
|
||||||
|
{
|
||||||
|
Width = 18, Height = 18, Fill = R("PrimaryBrush"), Stroke = Brushes.White,
|
||||||
|
StrokeThickness = 2, Cursor = Cursors.SizeAll, Tag = i,
|
||||||
|
};
|
||||||
|
handle.PreviewMouseLeftButtonDown += PerspectiveHandle_Down;
|
||||||
|
_perspectiveHandles[i] = handle;
|
||||||
|
_perspectiveCanvas.Children.Add(handle);
|
||||||
|
}
|
||||||
|
_perspectiveCanvas.AddHandler(Mouse.PreviewMouseMoveEvent,
|
||||||
|
new MouseEventHandler(PerspectiveCanvas_Move), true);
|
||||||
|
_perspectiveCanvas.AddHandler(Mouse.PreviewMouseUpEvent,
|
||||||
|
new MouseButtonEventHandler(PerspectiveCanvas_Up), true);
|
||||||
|
_perspectiveCanvas.LostMouseCapture += (_, _2) => _dragPerspective = -1;
|
||||||
|
previewGrid.Children.Add(_perspectiveCanvas);
|
||||||
|
|
||||||
|
previewWrap.Child = previewGrid;
|
||||||
|
_previewArea = previewWrap;
|
||||||
|
previewWrap.SizeChanged += (_, _2) => SizePreviewImage();
|
||||||
|
// Family shadow under the content pane, like the main window (flat on 98SE).
|
||||||
|
root.Children.Add(UiKit.PaneWithShadow(previewWrap));
|
||||||
|
|
||||||
|
Content = DialogChrome.Frame(this, Owner, "KillerPDF - " + S("Str_Tf_Suffix"), () => { Applied = false; Close(); }, root);
|
||||||
|
UpdatePreview(); // populate the output-size readout at the original dimensions
|
||||||
|
|
||||||
|
// Esc-to-close is wired by DialogChrome.Frame; Enter commits.
|
||||||
|
KeyDown += (_, e) => { if (e.Key == Key.Enter) CommitAndClose(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
private double Total => _quarter * 90 + _fine;
|
||||||
|
|
||||||
|
private void CommitAndClose()
|
||||||
|
{
|
||||||
|
Applied = true;
|
||||||
|
Angle = Total;
|
||||||
|
Scale = _scale;
|
||||||
|
FixedPage = _fixedPage;
|
||||||
|
FlipH = _flipH;
|
||||||
|
FlipV = _flipV;
|
||||||
|
PerspectiveCorners = PerspectiveCorners.ToArray();
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetPerspective()
|
||||||
|
{
|
||||||
|
PerspectiveCorners = [new(0, 0), new(1, 0), new(1, 1), new(0, 1)];
|
||||||
|
UpdatePerspectiveOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Rect PreviewBoundsOnPerspectiveCanvas()
|
||||||
|
{
|
||||||
|
if (_preview.ActualWidth <= 0 || _preview.ActualHeight <= 0) return Rect.Empty;
|
||||||
|
Point origin = _preview.TranslatePoint(new Point(0, 0), _perspectiveCanvas);
|
||||||
|
return new Rect(origin.X, origin.Y, _preview.ActualWidth, _preview.ActualHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePerspectiveOverlay()
|
||||||
|
{
|
||||||
|
if (_perspectiveCanvas == null || _perspectiveOutline == null) return;
|
||||||
|
Rect bounds = PreviewBoundsOnPerspectiveCanvas();
|
||||||
|
if (bounds.IsEmpty) return;
|
||||||
|
var points = new PointCollection();
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
{
|
||||||
|
Point p = new(bounds.Left + PerspectiveCorners[i].X * bounds.Width,
|
||||||
|
bounds.Top + PerspectiveCorners[i].Y * bounds.Height);
|
||||||
|
points.Add(p);
|
||||||
|
Canvas.SetLeft(_perspectiveHandles[i], p.X - 9);
|
||||||
|
Canvas.SetTop(_perspectiveHandles[i], p.Y - 9);
|
||||||
|
}
|
||||||
|
_perspectiveOutline.Points = points;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PerspectiveHandle_Down(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not Ellipse { Tag: int index }) return;
|
||||||
|
_dragPerspective = index;
|
||||||
|
Mouse.Capture(_perspectiveCanvas, CaptureMode.SubTree);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PerspectiveCanvas_Move(object sender, MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (_dragPerspective < 0 || e.LeftButton != MouseButtonState.Pressed) return;
|
||||||
|
Rect bounds = PreviewBoundsOnPerspectiveCanvas();
|
||||||
|
if (bounds.IsEmpty) return;
|
||||||
|
Point p = e.GetPosition(_perspectiveCanvas);
|
||||||
|
PerspectiveCorners[_dragPerspective] = new Point(
|
||||||
|
Math.Max(0, Math.Min(1, (p.X - bounds.Left) / bounds.Width)),
|
||||||
|
Math.Max(0, Math.Min(1, (p.Y - bounds.Top) / bounds.Height)));
|
||||||
|
UpdatePerspectiveOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PerspectiveCanvas_Up(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
_dragPerspective = -1;
|
||||||
|
if (ReferenceEquals(Mouse.Captured, _perspectiveCanvas)) Mouse.Capture(null);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Alignment-line deskew: drag a line, release, and the page rotates to make that line level. ----
|
||||||
|
// Maps a point in the preview image to page coordinates in points (clamped to the page).
|
||||||
|
private Point PreviewToPagePts(Point pInPreview)
|
||||||
|
{
|
||||||
|
double w = _preview.ActualWidth, h = _preview.ActualHeight;
|
||||||
|
double fx = w > 0 ? Math.Max(0, Math.Min(1, pInPreview.X / w)) : 0;
|
||||||
|
double fy = h > 0 ? Math.Max(0, Math.Min(1, pInPreview.Y / h)) : 0;
|
||||||
|
return new Point(fx * _pageWpt, fy * _pageHpt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowLineCoords(Point endPage)
|
||||||
|
=> _lineCoords.Text = $"Start {_startPagePt.X:0}, {_startPagePt.Y:0} pt\nEnd {endPage.X:0}, {endPage.Y:0} pt";
|
||||||
|
|
||||||
|
private void LineCanvas_Down(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
_drawingLine = true;
|
||||||
|
_lineStart = e.GetPosition(_lineCanvas);
|
||||||
|
_alignLine.X1 = _alignLine.X2 = _lineStart.X;
|
||||||
|
_alignLine.Y1 = _alignLine.Y2 = _lineStart.Y;
|
||||||
|
_alignLine.Visibility = Visibility.Visible;
|
||||||
|
_startPagePt = PreviewToPagePts(e.GetPosition(_preview));
|
||||||
|
ShowLineCoords(_startPagePt);
|
||||||
|
_lineCanvas.CaptureMouse();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LineCanvas_Move(object sender, MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_drawingLine) return;
|
||||||
|
var p = e.GetPosition(_lineCanvas);
|
||||||
|
_alignLine.X2 = p.X;
|
||||||
|
_alignLine.Y2 = p.Y;
|
||||||
|
ShowLineCoords(PreviewToPagePts(e.GetPosition(_preview)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LineCanvas_Up(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_drawingLine) return;
|
||||||
|
_drawingLine = false;
|
||||||
|
_lineCanvas.ReleaseMouseCapture();
|
||||||
|
|
||||||
|
double dx = _alignLine.X2 - _alignLine.X1;
|
||||||
|
double dy = _alignLine.Y2 - _alignLine.Y1;
|
||||||
|
_alignLine.Visibility = Visibility.Collapsed;
|
||||||
|
if (dx * dx + dy * dy < 100) return; // ignore an accidental tap
|
||||||
|
|
||||||
|
// Screen angle of the line (clockwise positive, since Y is down). Normalize to an undirected
|
||||||
|
// (-90, 90], then snap to the nearest axis so a near-vertical drag deskews to vertical.
|
||||||
|
double a = Math.Atan2(dy, dx) * 180.0 / Math.PI;
|
||||||
|
a %= 180.0;
|
||||||
|
if (a > 90.0) a -= 180.0; else if (a < -90.0) a += 180.0;
|
||||||
|
if (a > 45.0) a -= 90.0; else if (a < -45.0) a += 90.0;
|
||||||
|
|
||||||
|
// Rotate by -a (on top of the current fine angle) to level the line; the slider drives _fine.
|
||||||
|
double newFine = Math.Max(-45.0, Math.Min(45.0, _fine - a));
|
||||||
|
_rotSlider.Value = Math.Round(newFine, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttles the heavy preview compose so slider dragging stays smooth (see the timer in the ctor).
|
||||||
|
private void SchedulePreview()
|
||||||
|
{
|
||||||
|
_previewTimer.Stop();
|
||||||
|
_previewTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// #174 helpers, shared with the full-resolution Apply in Rotate.cs.
|
||||||
|
internal static bool LevelsIdentity(int black, int white, double gamma)
|
||||||
|
=> black <= 0 && white >= 255 && Math.Abs(gamma - 1.0) < 0.01;
|
||||||
|
|
||||||
|
/// <summary>Levels pass: remaps [black..white] to [0..255] through a midtone gamma,
|
||||||
|
/// per RGB channel, alpha untouched. Identity settings return the source unchanged.</summary>
|
||||||
|
internal static BitmapSource ApplyLevels(BitmapSource src, int black, int white, double gamma)
|
||||||
|
{
|
||||||
|
if (LevelsIdentity(black, white, gamma)) return src;
|
||||||
|
var conv = new FormatConvertedBitmap(src, PixelFormats.Bgra32, null, 0);
|
||||||
|
int w = conv.PixelWidth, h = conv.PixelHeight, stride = w * 4;
|
||||||
|
var px = new byte[stride * h];
|
||||||
|
conv.CopyPixels(px, stride, 0);
|
||||||
|
var lut = new byte[256];
|
||||||
|
double lo = black, hi = Math.Max(black + 1, white), invG = 1.0 / Math.Max(0.05, gamma);
|
||||||
|
for (int i = 0; i < 256; i++)
|
||||||
|
{
|
||||||
|
double t = (i - lo) / (hi - lo);
|
||||||
|
t = t < 0 ? 0 : t > 1 ? 1 : t;
|
||||||
|
lut[i] = (byte)Math.Round(Math.Pow(t, invG) * 255);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < px.Length; i += 4)
|
||||||
|
{
|
||||||
|
px[i] = lut[px[i]];
|
||||||
|
px[i + 1] = lut[px[i + 1]];
|
||||||
|
px[i + 2] = lut[px[i + 2]];
|
||||||
|
}
|
||||||
|
var bmp = BitmapSource.Create(w, h, conv.DpiX, conv.DpiY, PixelFormats.Bgra32, null, px, stride);
|
||||||
|
bmp.Freeze();
|
||||||
|
return bmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetLevels()
|
||||||
|
{
|
||||||
|
_lvlBlack.Value = 0; _lvlWhite.Value = 255; _lvlGamma.Value = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TextBlock SliderLabel(string text) => new()
|
||||||
|
{
|
||||||
|
Text = text, Foreground = R("MutedTextBrush"), FontFamily = UiKit.UiFont,
|
||||||
|
FontSize = 10, Margin = new Thickness(0, 6, 0, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
private void UpdatePreview()
|
||||||
|
{
|
||||||
|
double total = Total;
|
||||||
|
if (_rotReadout != null) _rotReadout.Text = $"{total:0.0}°";
|
||||||
|
_preview.Source = (total == 0 && _scale == 1.0 && !_flipH && !_flipV)
|
||||||
|
? _src
|
||||||
|
: MainWindow.ComposeTransform(_src, total, _scale, _fixedPage, _flipH, _flipV);
|
||||||
|
// #174: levels ride on top of whatever geometry the preview shows.
|
||||||
|
if (_preview.Source is BitmapSource lvlSrc && !LevelsIdentity(LevelBlack, LevelWhite, LevelGamma))
|
||||||
|
_preview.Source = ApplyLevels(lvlSrc, LevelBlack, LevelWhite, LevelGamma);
|
||||||
|
|
||||||
|
if (_sizeReadout != null && _preview.Source is BitmapSource b && _srcW > 0 && _pageWpt > 0)
|
||||||
|
{
|
||||||
|
double outWin = b.PixelWidth * (_pageWpt / _srcW) / 72.0;
|
||||||
|
double outHin = b.PixelHeight * (_pageHpt / _srcH) / 72.0;
|
||||||
|
_sizeReadout.Text = string.Format(S("Str_Tf_Output"), outWin.ToString("0.0"), outHin.ToString("0.0"));
|
||||||
|
}
|
||||||
|
SizePreviewImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sizes the page to its TRUE relative scale within the preview box, so "Resize the whole page" makes
|
||||||
|
// the page visibly shrink (rather than refit to the same size), and rotation visibly grows it.
|
||||||
|
// Clamps so the page never overflows the box.
|
||||||
|
private void SizePreviewImage()
|
||||||
|
{
|
||||||
|
if (_previewArea is null || _preview.Source is not BitmapSource bmp || _srcW <= 0 || _srcH <= 0) return;
|
||||||
|
const double m = 36; // breathing room inside the box
|
||||||
|
double areaW = Math.Max(1, _previewArea.ActualWidth - m);
|
||||||
|
double areaH = Math.Max(1, _previewArea.ActualHeight - m);
|
||||||
|
double baseFit = Math.Min(areaW / _srcW, areaH / _srcH); // scale that fits the original page
|
||||||
|
double dispW = bmp.PixelWidth * baseFit;
|
||||||
|
double dispH = bmp.PixelHeight * baseFit;
|
||||||
|
double clamp = Math.Min(1.0, Math.Min(areaW / dispW, areaH / dispH)); // never overflow the box
|
||||||
|
_preview.Width = dispW * clamp;
|
||||||
|
_preview.Height = dispH * clamp;
|
||||||
|
Dispatcher.BeginInvoke(new Action(UpdatePerspectiveOverlay), DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TextBlock SectionHeader(string text) => new()
|
||||||
|
{
|
||||||
|
Text = text, Foreground = R("MutedTextBrush"), FontFamily = UiKit.UiFont,
|
||||||
|
FontSize = 10, FontWeight = FontWeights.SemiBold, Margin = new Thickness(0, 6, 0, 4)
|
||||||
|
};
|
||||||
|
|
||||||
|
private void WrapSection(StackPanel host, int start, string title, bool expanded)
|
||||||
|
{
|
||||||
|
var children = host.Children.Cast<UIElement>().Skip(start).ToList();
|
||||||
|
while (host.Children.Count > start) host.Children.RemoveAt(start);
|
||||||
|
var body = new StackPanel { Visibility = expanded ? Visibility.Visible : Visibility.Collapsed };
|
||||||
|
foreach (var child in children) body.Children.Add(child);
|
||||||
|
var chevron = new TextBlock
|
||||||
|
{
|
||||||
|
Text = expanded ? "▾" : "▸", Width = 16, FontSize = 12,
|
||||||
|
Foreground = R("MutedTextBrush"), VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
};
|
||||||
|
var label = SectionHeader(title);
|
||||||
|
label.Margin = new Thickness(0);
|
||||||
|
var row = new StackPanel { Orientation = Orientation.Horizontal };
|
||||||
|
row.Children.Add(chevron);
|
||||||
|
row.Children.Add(label);
|
||||||
|
var header = new Border
|
||||||
|
{
|
||||||
|
Background = Brushes.Transparent, Cursor = Cursors.Hand,
|
||||||
|
Padding = new Thickness(0, 5, 0, 5), Child = row,
|
||||||
|
};
|
||||||
|
header.MouseLeftButtonUp += (_, _2) =>
|
||||||
|
{
|
||||||
|
bool open = body.Visibility != Visibility.Visible;
|
||||||
|
body.Visibility = open ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
chevron.Text = open ? "▾" : "▸";
|
||||||
|
};
|
||||||
|
host.Children.Add(header);
|
||||||
|
host.Children.Add(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Border Divider()
|
||||||
|
{
|
||||||
|
var b = new Border { Height = 1, Margin = new Thickness(0, 14, 0, 12) };
|
||||||
|
b.SetResourceReference(Border.BackgroundProperty, "CardBorderBrush");
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DockPanel ValueRow(string label, string value, out TextBlock valueBlock, out Button reset)
|
||||||
|
{
|
||||||
|
var row = new DockPanel { Margin = new Thickness(0, 2, 0, 0) };
|
||||||
|
reset = UiKit.Make(S("Str_Tf_Reset"), false);
|
||||||
|
reset.Padding = new Thickness(8, 1, 8, 1);
|
||||||
|
reset.FontSize = 11;
|
||||||
|
DockPanel.SetDock(reset, Dock.Right);
|
||||||
|
row.Children.Add(reset);
|
||||||
|
valueBlock = new TextBlock
|
||||||
|
{
|
||||||
|
Text = value, Foreground = R("TextBrush"), FontFamily = UiKit.MonoFont,
|
||||||
|
FontSize = 12, VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
TextAlignment = TextAlignment.Right, Margin = new Thickness(0, 0, 8, 0)
|
||||||
|
};
|
||||||
|
DockPanel.SetDock(valueBlock, Dock.Right);
|
||||||
|
row.Children.Add(valueBlock);
|
||||||
|
row.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = label, Foreground = R("MutedTextBrush"), FontFamily = UiKit.UiFont,
|
||||||
|
FontSize = 11, VerticalAlignment = VerticalAlignment.Center
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RadioButton MakeRadio(string text, bool isChecked, Style? style)
|
||||||
|
{
|
||||||
|
var rb = new RadioButton
|
||||||
|
{
|
||||||
|
Content = new TextBlock { Text = text, TextWrapping = TextWrapping.Wrap, VerticalAlignment = VerticalAlignment.Center },
|
||||||
|
IsChecked = isChecked, Foreground = R("TextBrush"),
|
||||||
|
FontFamily = UiKit.UiFont, FontSize = 12, Margin = new Thickness(0, 3, 0, 0)
|
||||||
|
};
|
||||||
|
if (style != null) rb.Style = style;
|
||||||
|
return rb;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CheckBox MakeCheck(string text)
|
||||||
|
{
|
||||||
|
var cb = UiKit.CheckBox(text);
|
||||||
|
cb.Margin = new Thickness(0, 3, 0, 0);
|
||||||
|
return cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,579 @@
|
|||||||
|
using System;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Controls.Primitives;
|
||||||
|
using System.Windows.Data;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Animation;
|
||||||
|
using System.Windows.Media.Effects;
|
||||||
|
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
// WPF has no built-in animation for GridLength, so grid columns (the sidebar) can only snap.
|
||||||
|
// This drives a pixel-unit GridLength between From and To so a column glides instead.
|
||||||
|
// From/To/Easing MUST be dependency properties: starting the clock freezes/clones the
|
||||||
|
// timeline, and the clone only carries DPs - as plain CLR properties they were lost and
|
||||||
|
// the "animation" held a constant until the completion snap.
|
||||||
|
internal sealed class GridLengthAnimation : AnimationTimeline
|
||||||
|
{
|
||||||
|
public static readonly DependencyProperty FromProperty =
|
||||||
|
DependencyProperty.Register(nameof(From), typeof(GridLength), typeof(GridLengthAnimation));
|
||||||
|
public static readonly DependencyProperty ToProperty =
|
||||||
|
DependencyProperty.Register(nameof(To), typeof(GridLength), typeof(GridLengthAnimation));
|
||||||
|
public static readonly DependencyProperty EasingProperty =
|
||||||
|
DependencyProperty.Register(nameof(Easing), typeof(IEasingFunction), typeof(GridLengthAnimation));
|
||||||
|
|
||||||
|
public GridLength From { get => (GridLength)GetValue(FromProperty); set => SetValue(FromProperty, value); }
|
||||||
|
public GridLength To { get => (GridLength)GetValue(ToProperty); set => SetValue(ToProperty, value); }
|
||||||
|
public IEasingFunction? Easing { get => (IEasingFunction?)GetValue(EasingProperty); set => SetValue(EasingProperty, value); }
|
||||||
|
|
||||||
|
public override Type TargetPropertyType => typeof(GridLength);
|
||||||
|
protected override Freezable CreateInstanceCore() => new GridLengthAnimation();
|
||||||
|
public override object GetCurrentValue(object defaultOriginValue, object defaultDestinationValue, AnimationClock animationClock)
|
||||||
|
{
|
||||||
|
double p = animationClock.CurrentProgress ?? 0.0;
|
||||||
|
if (Easing is { } ease) p = ease.Ease(p);
|
||||||
|
return new GridLength(From.Value + (To.Value - From.Value) * p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Design tokens (fonts, radii, shadows) and code-built controls (buttons, checkboxes, fields, labels)
|
||||||
|
// for dialogs and tools. Tokens resolve from App.xaml's resource dictionary.
|
||||||
|
internal static class UiKit
|
||||||
|
{
|
||||||
|
// Mouse-wheel over ANY slider (on hover) nudges its value - one global class handler covers every
|
||||||
|
// slider in the app and all dialogs. Handled so the wheel doesn't also scroll an enclosing panel
|
||||||
|
// while the cursor is on the slider. Registered once when UiKit is first touched (early in startup).
|
||||||
|
static UiKit()
|
||||||
|
{
|
||||||
|
EventManager.RegisterClassHandler(typeof(Slider), UIElement.PreviewMouseWheelEvent,
|
||||||
|
new MouseWheelEventHandler(SliderWheelAdjust));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SliderWheelAdjust(object sender, MouseWheelEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not Slider s || !s.IsEnabled) return;
|
||||||
|
double step = s.SmallChange > 0 ? s.SmallChange : (s.Maximum - s.Minimum) / 20.0;
|
||||||
|
if (step <= 0) return;
|
||||||
|
s.Value = Math.Max(s.Minimum, Math.Min(s.Maximum, s.Value + (e.Delta > 0 ? step : -step)));
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- token + theme accessors -------------------------------------------------------------
|
||||||
|
public static FontFamily UiFont => Res("UiFont", _uiFallback);
|
||||||
|
public static FontFamily MonoFont => Res("MonoFont", _monoFallback);
|
||||||
|
public static FontFamily IconFont => Res("IconFont", _iconFallback);
|
||||||
|
public static FontFamily WordmarkFont => Res("WordmarkFont", _wordmarkFallback);
|
||||||
|
public static FontFamily WordmarkFontPdf => Res("WordmarkFontPdf", _wordmarkPdfFallback);
|
||||||
|
private static readonly FontFamily _uiFallback = new("Segoe UI, Microsoft JhengHei UI, Nirmala UI");
|
||||||
|
private static readonly FontFamily _monoFallback = new("Consolas");
|
||||||
|
private static readonly FontFamily _iconFallback = new("Segoe MDL2 Assets");
|
||||||
|
private static readonly FontFamily _wordmarkFallback = new("Typewriter - a602 (dead postman 2004), Consolas");
|
||||||
|
private static readonly FontFamily _wordmarkPdfFallback = new("Typewriter - a602 (dead postman 2004), Consolas");
|
||||||
|
|
||||||
|
public static CornerRadius RadControl => Rad("RadControl", 3);
|
||||||
|
public static CornerRadius RadCard => Rad("RadCard", 6);
|
||||||
|
public static CornerRadius RadWindow => Rad("RadWindow", 7);
|
||||||
|
|
||||||
|
// Fresh shadow instances (cheap) matching App.xaml's Shadow* resources, for code that builds Effects.
|
||||||
|
public static DropShadowEffect ShadowText() => Shadow(3, 1, 0.6);
|
||||||
|
public static DropShadowEffect ShadowIcon() => Shadow(4, 1, 0.9);
|
||||||
|
public static DropShadowEffect ShadowBar() => Shadow(6, 3, Opacity("BarShadowOpacity", 0.38));
|
||||||
|
public static DropShadowEffect ShadowDialog() => Shadow(18, 3, Opacity("FlyoutShadowOpacity", 0.6));
|
||||||
|
|
||||||
|
// Active-theme brush by key, with a safe fallback so the kit never throws before the theme loads.
|
||||||
|
public static Brush Brush(string key, Brush? fallback = null)
|
||||||
|
=> Application.Current?.TryFindResource(key) as Brush ?? fallback ?? Brushes.Gray;
|
||||||
|
|
||||||
|
// ---- inline flyout -----------------------------------------------------------------------
|
||||||
|
// The style for small helpers that float ON the document itself (the form font-size
|
||||||
|
// stepper; future on-page controls): a translucent dark pill that reads over any page
|
||||||
|
// content without shouting. Slightly see-through at rest so the page underneath stays
|
||||||
|
// visible; hovering solidifies it (animated). Deliberately theme-independent - pages are
|
||||||
|
// usually white whatever the app theme, so one consistent dark pill (with fixed light
|
||||||
|
// text inside) reads best everywhere.
|
||||||
|
public const double InlineFlyoutRestOpacity = 0.85;
|
||||||
|
|
||||||
|
public static Border InlineFlyout(FrameworkElement content)
|
||||||
|
{
|
||||||
|
var b = new Border
|
||||||
|
{
|
||||||
|
CornerRadius = new CornerRadius(12),
|
||||||
|
Padding = new Thickness(7, 1, 7, 1),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
Background = new SolidColorBrush(Color.FromArgb(0xCC, 0x16, 0x16, 0x16)),
|
||||||
|
BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xFF, 0xFF, 0xFF)),
|
||||||
|
Effect = Shadow(10, 2, 0.25),
|
||||||
|
Child = content,
|
||||||
|
SnapsToDevicePixels = true,
|
||||||
|
};
|
||||||
|
b.MouseEnter += (_, _) => b.BeginAnimation(UIElement.OpacityProperty,
|
||||||
|
new DoubleAnimation(1.0, new Duration(TimeSpan.FromMilliseconds(110)))
|
||||||
|
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
|
||||||
|
b.MouseLeave += (_, _) => b.BeginAnimation(UIElement.OpacityProperty,
|
||||||
|
new DoubleAnimation(InlineFlyoutRestOpacity, new Duration(TimeSpan.FromMilliseconds(220)))
|
||||||
|
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T Res<T>(string key, T fallback) where T : class
|
||||||
|
=> Application.Current?.TryFindResource(key) as T ?? fallback;
|
||||||
|
private static CornerRadius Rad(string key, double fb)
|
||||||
|
=> Application.Current?.TryFindResource(key) is CornerRadius c ? c : new CornerRadius(fb);
|
||||||
|
private static DropShadowEffect Shadow(double blur, double depth, double opacity)
|
||||||
|
=> new() { Color = Colors.Black, BlurRadius = blur, ShadowDepth = depth, Direction = 270, Opacity = opacity };
|
||||||
|
private static double Opacity(string key, double fallback)
|
||||||
|
=> Application.Current?.TryFindResource(key) is double value ? value : fallback;
|
||||||
|
|
||||||
|
// The default quick-color palette, shared by the annotate bars and the color picker's swatch row
|
||||||
|
// (the "UserSwatches" setting seeds from this). One source so the two can't drift.
|
||||||
|
public static readonly Color[] DefaultSwatches =
|
||||||
|
[
|
||||||
|
Color.FromRgb(0xE0, 0x3C, 0x3C), Color.FromRgb(0xE8, 0x7A, 0x1E), Color.FromRgb(0xF2, 0xC0, 0x1E),
|
||||||
|
Color.FromRgb(0x2E, 0xA5, 0x4C), Color.FromRgb(0x2E, 0x86, 0xDE), Color.FromRgb(0x8E, 0x5B, 0xD6),
|
||||||
|
Color.FromRgb(0xE0, 0x4A, 0x9A), Colors.Black, Colors.White
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- control factories -------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Themed checkbox: rounded box with an accent check mark when checked. Replaces the per-dialog
|
||||||
|
// StyleCheckBox/ThemedCheckTemplate copies so every checkbox in the app is identical.
|
||||||
|
public static CheckBox CheckBox(string label) => new()
|
||||||
|
{
|
||||||
|
Content = new TextBlock { Text = label, TextWrapping = TextWrapping.Wrap },
|
||||||
|
Foreground = Brush("TextBrush"),
|
||||||
|
FontFamily = UiFont,
|
||||||
|
FontSize = 12,
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
VerticalContentAlignment = VerticalAlignment.Center,
|
||||||
|
Template = CheckTemplate()
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ControlTemplate CheckTemplate()
|
||||||
|
{
|
||||||
|
// DockPanel, not a horizontal StackPanel. A horizontal StackPanel measures its children
|
||||||
|
// at infinite width, so the label could never wrap however it was configured. Docking the
|
||||||
|
// box to the left leaves the label a real width to wrap inside (#223).
|
||||||
|
var row = new FrameworkElementFactory(typeof(DockPanel)) { Name = "root" };
|
||||||
|
|
||||||
|
var boxHost = new FrameworkElementFactory(typeof(Grid));
|
||||||
|
boxHost.SetValue(FrameworkElement.WidthProperty, 16.0);
|
||||||
|
boxHost.SetValue(FrameworkElement.HeightProperty, 16.0);
|
||||||
|
boxHost.SetValue(FrameworkElement.VerticalAlignmentProperty, VerticalAlignment.Center);
|
||||||
|
boxHost.SetValue(FrameworkElement.MarginProperty, new Thickness(0, 0, 8, 0));
|
||||||
|
boxHost.SetValue(DockPanel.DockProperty, Dock.Left);
|
||||||
|
|
||||||
|
var box = new FrameworkElementFactory(typeof(Border));
|
||||||
|
box.SetValue(Border.CornerRadiusProperty, RadControl);
|
||||||
|
box.SetValue(Border.BorderThicknessProperty, new Thickness(1));
|
||||||
|
box.SetValue(Border.BorderBrushProperty, Brush("CardBorderBrush"));
|
||||||
|
box.SetValue(Border.BackgroundProperty, Brush("RadioWellBrush"));
|
||||||
|
boxHost.AppendChild(box);
|
||||||
|
|
||||||
|
var sunkenDark = new FrameworkElementFactory(typeof(Border));
|
||||||
|
sunkenDark.SetValue(UIElement.IsHitTestVisibleProperty, false);
|
||||||
|
sunkenDark.SetResourceReference(Border.BorderBrushProperty, "BevelDarkBrush");
|
||||||
|
sunkenDark.SetResourceReference(Border.BorderThicknessProperty, "CheckSunkenDarkThickness");
|
||||||
|
boxHost.AppendChild(sunkenDark);
|
||||||
|
|
||||||
|
var sunkenLight = new FrameworkElementFactory(typeof(Border));
|
||||||
|
sunkenLight.SetValue(UIElement.IsHitTestVisibleProperty, false);
|
||||||
|
sunkenLight.SetResourceReference(Border.BorderBrushProperty, "BevelLightBrush");
|
||||||
|
sunkenLight.SetResourceReference(Border.BorderThicknessProperty, "CheckSunkenLightThickness");
|
||||||
|
boxHost.AppendChild(sunkenLight);
|
||||||
|
|
||||||
|
var check = new FrameworkElementFactory(typeof(TextBlock)) { Name = "chk" };
|
||||||
|
check.SetValue(TextBlock.TextProperty, ""); // Segoe MDL2 CheckMark
|
||||||
|
check.SetValue(TextBlock.FontFamilyProperty, IconFont);
|
||||||
|
check.SetValue(TextBlock.FontSizeProperty, 14.0);
|
||||||
|
check.SetValue(TextBlock.FontWeightProperty, FontWeights.Bold);
|
||||||
|
check.SetValue(TextBlock.ForegroundProperty, Brush("RadioAccent"));
|
||||||
|
check.SetValue(TextBlock.HorizontalAlignmentProperty, HorizontalAlignment.Center);
|
||||||
|
check.SetValue(TextBlock.VerticalAlignmentProperty, VerticalAlignment.Center);
|
||||||
|
check.SetValue(UIElement.VisibilityProperty, Visibility.Collapsed);
|
||||||
|
boxHost.AppendChild(check);
|
||||||
|
|
||||||
|
var content = new FrameworkElementFactory(typeof(ContentPresenter));
|
||||||
|
content.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center);
|
||||||
|
|
||||||
|
row.AppendChild(boxHost);
|
||||||
|
row.AppendChild(content);
|
||||||
|
|
||||||
|
var ct = new ControlTemplate(typeof(CheckBox)) { VisualTree = row };
|
||||||
|
var trig = new Trigger { Property = ToggleButton.IsCheckedProperty, Value = true };
|
||||||
|
trig.Setters.Add(new Setter(UIElement.VisibilityProperty, Visibility.Visible) { TargetName = "chk" });
|
||||||
|
ct.Triggers.Add(trig);
|
||||||
|
// Disabled state: dim the whole control (box + label) so it's obviously inactive.
|
||||||
|
var disabled = new Trigger { Property = UIElement.IsEnabledProperty, Value = false };
|
||||||
|
disabled.Setters.Add(new Setter(UIElement.OpacityProperty, 0.4) { TargetName = "root" });
|
||||||
|
ct.Triggers.Add(disabled);
|
||||||
|
return ct;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Themed radio button with a clean horizontal layout (ring + accent dot + label), built from the
|
||||||
|
// theme brushes. Unlike the settings-panel ThemeRadio (a full-width vertical row), this lays out
|
||||||
|
// tightly for inline/horizontal use.
|
||||||
|
public static RadioButton Radio(string text) => new()
|
||||||
|
{
|
||||||
|
Content = text,
|
||||||
|
Foreground = Brush("TextBrush"),
|
||||||
|
FontFamily = UiFont,
|
||||||
|
FontSize = 12,
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
VerticalContentAlignment = VerticalAlignment.Center,
|
||||||
|
Template = RadioTemplate()
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ControlTemplate RadioTemplate()
|
||||||
|
{
|
||||||
|
var sp = new FrameworkElementFactory(typeof(StackPanel)) { Name = "root" };
|
||||||
|
sp.SetValue(StackPanel.OrientationProperty, Orientation.Horizontal);
|
||||||
|
sp.SetValue(Panel.BackgroundProperty, Brushes.Transparent);
|
||||||
|
|
||||||
|
var ring = new FrameworkElementFactory(typeof(Border)) { Name = "ring" };
|
||||||
|
ring.SetValue(Border.WidthProperty, 15.0);
|
||||||
|
ring.SetValue(Border.HeightProperty, 15.0);
|
||||||
|
ring.SetValue(Border.CornerRadiusProperty, new CornerRadius(7.5));
|
||||||
|
ring.SetValue(Border.BorderThicknessProperty, new Thickness(1.5));
|
||||||
|
ring.SetValue(Border.BorderBrushProperty, Brush("DimTextBrush"));
|
||||||
|
ring.SetValue(Border.BackgroundProperty, Brush("RadioWellBrush"));
|
||||||
|
ring.SetValue(Border.VerticalAlignmentProperty, VerticalAlignment.Center);
|
||||||
|
ring.SetValue(Border.MarginProperty, new Thickness(0, 1, 7, 0)); // +1 top settles it against the text optical center
|
||||||
|
|
||||||
|
var dot = new FrameworkElementFactory(typeof(Border)) { Name = "dot" };
|
||||||
|
dot.SetValue(Border.WidthProperty, 7.0);
|
||||||
|
dot.SetValue(Border.HeightProperty, 7.0);
|
||||||
|
dot.SetValue(Border.CornerRadiusProperty, new CornerRadius(3.5));
|
||||||
|
dot.SetValue(Border.BackgroundProperty, Brush("RadioAccent"));
|
||||||
|
dot.SetValue(Border.HorizontalAlignmentProperty, HorizontalAlignment.Center);
|
||||||
|
dot.SetValue(Border.VerticalAlignmentProperty, VerticalAlignment.Center);
|
||||||
|
dot.SetValue(UIElement.VisibilityProperty, Visibility.Collapsed);
|
||||||
|
ring.AppendChild(dot);
|
||||||
|
|
||||||
|
var cp = new FrameworkElementFactory(typeof(ContentPresenter));
|
||||||
|
cp.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center);
|
||||||
|
|
||||||
|
sp.AppendChild(ring);
|
||||||
|
sp.AppendChild(cp);
|
||||||
|
|
||||||
|
var ct = new ControlTemplate(typeof(RadioButton)) { VisualTree = sp };
|
||||||
|
var on = new Trigger { Property = ToggleButton.IsCheckedProperty, Value = true };
|
||||||
|
on.Setters.Add(new Setter(UIElement.VisibilityProperty, Visibility.Visible) { TargetName = "dot" });
|
||||||
|
on.Setters.Add(new Setter(Border.BorderBrushProperty, Brush("RadioAccent")) { TargetName = "ring" });
|
||||||
|
ct.Triggers.Add(on);
|
||||||
|
var off = new Trigger { Property = UIElement.IsEnabledProperty, Value = false };
|
||||||
|
off.Setters.Add(new Setter(UIElement.OpacityProperty, 0.4) { TargetName = "root" });
|
||||||
|
ct.Triggers.Add(off);
|
||||||
|
return ct;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Themed single-line input, fully self-contained (templated from the theme brushes) so it renders
|
||||||
|
// correctly in ANY window without depending on a window-scoped XAML style. Kills the OS-default
|
||||||
|
// white box / blue focus + selection chrome.
|
||||||
|
public static TextBox Field(double width = double.NaN)
|
||||||
|
{
|
||||||
|
var tb = new TextBox
|
||||||
|
{
|
||||||
|
FontFamily = UiFont,
|
||||||
|
FontSize = 12,
|
||||||
|
Background = Brush("TextFieldBrush", Brush("BgCanvas")),
|
||||||
|
Foreground = Brush("TextBrush"),
|
||||||
|
BorderBrush = Brush("CardBorderBrush"),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
Padding = new Thickness(6, 4, 6, 4),
|
||||||
|
CaretBrush = Brush("TextBrush"),
|
||||||
|
SelectionBrush = Brush("RowSelectedBrush"),
|
||||||
|
SelectionTextBrush = Brush("TextBrush"),
|
||||||
|
Template = FieldTemplate()
|
||||||
|
};
|
||||||
|
if (!double.IsNaN(width)) tb.Width = width;
|
||||||
|
return tb;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ControlTemplate FieldTemplate()
|
||||||
|
{
|
||||||
|
var root = new FrameworkElementFactory(typeof(Grid));
|
||||||
|
var b = new FrameworkElementFactory(typeof(Border));
|
||||||
|
b.SetBinding(Border.BackgroundProperty, new Binding("Background") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
|
||||||
|
b.SetBinding(Border.BorderBrushProperty, new Binding("BorderBrush") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
|
||||||
|
b.SetBinding(Border.BorderThicknessProperty, new Binding("BorderThickness") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
|
||||||
|
b.SetValue(Border.CornerRadiusProperty, RadControl);
|
||||||
|
var sv = new FrameworkElementFactory(typeof(ScrollViewer)) { Name = "PART_ContentHost" };
|
||||||
|
sv.SetValue(Control.PaddingProperty, new Thickness(0));
|
||||||
|
b.AppendChild(sv);
|
||||||
|
root.AppendChild(b);
|
||||||
|
|
||||||
|
var dark = new FrameworkElementFactory(typeof(Border));
|
||||||
|
dark.SetValue(UIElement.IsHitTestVisibleProperty, false);
|
||||||
|
dark.SetValue(Border.CornerRadiusProperty, RadControl);
|
||||||
|
dark.SetResourceReference(Border.BorderBrushProperty, "BevelDarkBrush");
|
||||||
|
dark.SetResourceReference(Border.BorderThicknessProperty, "CheckSunkenDarkThickness");
|
||||||
|
root.AppendChild(dark);
|
||||||
|
|
||||||
|
var light = new FrameworkElementFactory(typeof(Border));
|
||||||
|
light.SetValue(UIElement.IsHitTestVisibleProperty, false);
|
||||||
|
light.SetValue(Border.CornerRadiusProperty, RadControl);
|
||||||
|
light.SetResourceReference(Border.BorderBrushProperty, "BevelLightBrush");
|
||||||
|
light.SetResourceReference(Border.BorderThicknessProperty, "CheckSunkenLightThickness");
|
||||||
|
root.AppendChild(light);
|
||||||
|
return new ControlTemplate(typeof(TextBox)) { VisualTree = root };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Themed PasswordBox matching Field(): our border/fill, no OS white box or blue focus chrome.
|
||||||
|
public static PasswordBox PasswordField(double width = double.NaN)
|
||||||
|
{
|
||||||
|
var pb = new PasswordBox
|
||||||
|
{
|
||||||
|
FontFamily = UiFont,
|
||||||
|
FontSize = 12,
|
||||||
|
Background = Brush("TextFieldBrush", Brush("BgCanvas")),
|
||||||
|
Foreground = Brush("TextBrush"),
|
||||||
|
BorderBrush = Brush("CardBorderBrush"),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
Padding = new Thickness(6, 5, 6, 5),
|
||||||
|
CaretBrush = Brush("TextBrush"),
|
||||||
|
Template = PasswordFieldTemplate()
|
||||||
|
};
|
||||||
|
if (!double.IsNaN(width)) pb.Width = width;
|
||||||
|
return pb;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ControlTemplate PasswordFieldTemplate()
|
||||||
|
{
|
||||||
|
var root = new FrameworkElementFactory(typeof(Grid));
|
||||||
|
var b = new FrameworkElementFactory(typeof(Border));
|
||||||
|
b.SetBinding(Border.BackgroundProperty, new Binding("Background") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
|
||||||
|
b.SetBinding(Border.BorderBrushProperty, new Binding("BorderBrush") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
|
||||||
|
b.SetBinding(Border.BorderThicknessProperty, new Binding("BorderThickness") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
|
||||||
|
b.SetValue(Border.CornerRadiusProperty, RadControl);
|
||||||
|
var sv = new FrameworkElementFactory(typeof(ScrollViewer)) { Name = "PART_ContentHost" };
|
||||||
|
sv.SetValue(Control.PaddingProperty, new Thickness(0));
|
||||||
|
b.AppendChild(sv);
|
||||||
|
root.AppendChild(b);
|
||||||
|
|
||||||
|
var dark = new FrameworkElementFactory(typeof(Border));
|
||||||
|
dark.SetValue(UIElement.IsHitTestVisibleProperty, false);
|
||||||
|
dark.SetValue(Border.CornerRadiusProperty, RadControl);
|
||||||
|
dark.SetResourceReference(Border.BorderBrushProperty, "BevelDarkBrush");
|
||||||
|
dark.SetResourceReference(Border.BorderThicknessProperty, "CheckSunkenDarkThickness");
|
||||||
|
root.AppendChild(dark);
|
||||||
|
|
||||||
|
var light = new FrameworkElementFactory(typeof(Border));
|
||||||
|
light.SetValue(UIElement.IsHitTestVisibleProperty, false);
|
||||||
|
light.SetValue(Border.CornerRadiusProperty, RadControl);
|
||||||
|
light.SetResourceReference(Border.BorderBrushProperty, "BevelLightBrush");
|
||||||
|
light.SetResourceReference(Border.BorderThicknessProperty, "CheckSunkenLightThickness");
|
||||||
|
root.AppendChild(light);
|
||||||
|
return new ControlTemplate(typeof(PasswordBox)) { VisualTree = root };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wraps a dialog's document/preview pane with the family drop shadow: a SEPARATE sibling
|
||||||
|
// border underneath (content must never render through a bitmap effect or it loses
|
||||||
|
// ClearType), carrying the per-theme PaneShadowEffect - which is null on 98SE, so the
|
||||||
|
// classic theme stays flat. Dialogs read as mini main windows this way.
|
||||||
|
public static Grid PaneWithShadow(Border pane)
|
||||||
|
{
|
||||||
|
// Code-built preview panes used to carry their own hard-coded radius, so 98SE could
|
||||||
|
// never square them. The theme only supplies this override when it needs one.
|
||||||
|
if (Application.Current?.TryFindResource("PaneCornerRadiusValue") is double radius)
|
||||||
|
pane.CornerRadius = new CornerRadius(radius);
|
||||||
|
|
||||||
|
var shadow = new Border
|
||||||
|
{
|
||||||
|
Margin = pane.Margin,
|
||||||
|
CornerRadius = pane.CornerRadius,
|
||||||
|
IsHitTestVisible = false,
|
||||||
|
};
|
||||||
|
shadow.SetResourceReference(Border.BackgroundProperty, "BgCanvas");
|
||||||
|
shadow.SetResourceReference(UIElement.EffectProperty, "PaneShadowEffect");
|
||||||
|
var host = new Grid();
|
||||||
|
host.Children.Add(shadow);
|
||||||
|
host.Children.Add(pane);
|
||||||
|
|
||||||
|
// The main document pane uses these same four bevel rings. They are transparent and
|
||||||
|
// zero-width on modern themes, while 98SE gets its square two-stage classic recess.
|
||||||
|
var bevels = new Grid { Margin = pane.Margin, IsHitTestVisible = false };
|
||||||
|
Border Ring(string brushKey, string thicknessKey, bool inner = false)
|
||||||
|
{
|
||||||
|
var ring = new Border { CornerRadius = pane.CornerRadius };
|
||||||
|
ring.SetResourceReference(Border.BorderBrushProperty, brushKey);
|
||||||
|
ring.SetResourceReference(Border.BorderThicknessProperty, thicknessKey);
|
||||||
|
if (inner)
|
||||||
|
ring.SetResourceReference(FrameworkElement.MarginProperty, "PaneBevelInnerMargin");
|
||||||
|
return ring;
|
||||||
|
}
|
||||||
|
bevels.Children.Add(Ring("PaneBevelDarkBrush", "PaneBevelLightThickness"));
|
||||||
|
bevels.Children.Add(Ring("PaneBevelLightBrush", "PaneBevelDarkThickness"));
|
||||||
|
bevels.Children.Add(Ring("PaneBevelDark2Brush", "PaneBevel2LightThickness", inner: true));
|
||||||
|
bevels.Children.Add(Ring("PaneBevelLight2Brush", "PaneBevel2DarkThickness", inner: true));
|
||||||
|
host.Children.Add(bevels);
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dialog section heading (e.g. "ROTATE", "PAGE NUMBERS").
|
||||||
|
public static TextBlock SectionHeader(string text) => new()
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
FontFamily = MonoFont,
|
||||||
|
FontSize = 12,
|
||||||
|
FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = Brush("TextBrush"),
|
||||||
|
Margin = new Thickness(0, 0, 0, 6),
|
||||||
|
Effect = ShadowText()
|
||||||
|
};
|
||||||
|
|
||||||
|
// A small secondary label sitting above/beside a field.
|
||||||
|
public static TextBlock GroupLabel(string text) => new()
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
FontFamily = UiFont,
|
||||||
|
FontSize = 11,
|
||||||
|
Foreground = Brush("MutedTextBrush"),
|
||||||
|
Margin = new Thickness(0, 0, 0, 2)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Right-aligned row of dialog buttons with a consistent 8px gap. Pass buttons left-to-right.
|
||||||
|
public static StackPanel ButtonRow(params Button[] buttons)
|
||||||
|
{
|
||||||
|
var row = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right };
|
||||||
|
for (int i = 0; i < buttons.Length; i++)
|
||||||
|
{
|
||||||
|
if (i > 0) buttons[i].Margin = new Thickness(8, 0, 0, 0);
|
||||||
|
row.Children.Add(buttons[i]);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A flat text "link" (e.g. "Reset all") with an accent hover, for low-emphasis dialog actions.
|
||||||
|
public static TextBlock LinkLabel(string text, Action onClick)
|
||||||
|
{
|
||||||
|
var link = new TextBlock
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
FontFamily = UiFont,
|
||||||
|
FontSize = 12,
|
||||||
|
Foreground = Brush("MutedTextBrush"),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
link.MouseEnter += (_, _2) => link.Foreground = Brush("PrimaryBrush");
|
||||||
|
link.MouseLeave += (_, _2) => link.Foreground = Brush("MutedTextBrush");
|
||||||
|
link.MouseLeftButtonUp += (_, _2) => onClick();
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialog/popup buttons. accent==true is the primary (fills solid accent on hover); false is secondary.
|
||||||
|
public static Button Make(object content, bool accent)
|
||||||
|
{
|
||||||
|
if (KillerPDF.Services.ThemeManager.Current == KillerPDF.Services.Theme.SE98)
|
||||||
|
{
|
||||||
|
var button = Make(content, Brush("ChipFaceBrush"), Brush("ChipFaceBrush"),
|
||||||
|
Brush("TextBrush"), Brush("TextBrush"), Brushes.Transparent);
|
||||||
|
// Keep an already-open code-built surface attached to the live palette. The
|
||||||
|
// explicit-color factory below is also used by pre-theme startup dialogs, so the
|
||||||
|
// resource references belong here in the normal themed overload.
|
||||||
|
button.SetResourceReference(Control.BackgroundProperty, "ChipFaceBrush");
|
||||||
|
button.SetResourceReference(Control.ForegroundProperty, "TextBrush");
|
||||||
|
button.Template = BeveledButtonTemplate();
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
var themed = accent
|
||||||
|
? Make(content, Brush("SelectionBg"), Brush("PrimaryBrush"), Brush("SelectionFg"), Brush("OnPrimaryBrush"), Brush("PrimaryBrush"))
|
||||||
|
: Make(content, Brush("PaneBrush"), Brush("RowHoverBrush"), Brush("TextBrush"), Brush("TextBrush"), Brush("CardBorderBrush"));
|
||||||
|
|
||||||
|
// Make(object,bool) is used by long-lived annotation bars and modeless tool windows.
|
||||||
|
// A local brush value would preserve the palette that happened to be active when the
|
||||||
|
// control was constructed. Re-attach each state to resource keys so theme and accent
|
||||||
|
// changes repaint the existing button instead of leaving an old-colored island.
|
||||||
|
void ApplyRest()
|
||||||
|
{
|
||||||
|
themed.SetResourceReference(Control.BackgroundProperty, accent ? "SelectionBg" : "PaneBrush");
|
||||||
|
themed.SetResourceReference(Control.ForegroundProperty, accent ? "SelectionFg" : "TextBrush");
|
||||||
|
themed.SetResourceReference(Control.BorderBrushProperty, accent ? "PrimaryBrush" : "CardBorderBrush");
|
||||||
|
}
|
||||||
|
void ApplyHover()
|
||||||
|
{
|
||||||
|
themed.SetResourceReference(Control.BackgroundProperty, accent ? "PrimaryBrush" : "RowHoverBrush");
|
||||||
|
themed.SetResourceReference(Control.ForegroundProperty, accent ? "OnPrimaryBrush" : "TextBrush");
|
||||||
|
themed.SetResourceReference(Control.BorderBrushProperty, accent ? "PrimaryBrush" : "CardBorderBrush");
|
||||||
|
}
|
||||||
|
|
||||||
|
// These handlers are registered after the explicit-color factory's handlers, so the
|
||||||
|
// resource-backed values win and remain live for the current palette.
|
||||||
|
themed.MouseEnter += (_, _) => ApplyHover();
|
||||||
|
themed.MouseLeave += (_, _) => ApplyRest();
|
||||||
|
ApplyRest();
|
||||||
|
return themed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ControlTemplate BeveledButtonTemplate()
|
||||||
|
{
|
||||||
|
var grid = new FrameworkElementFactory(typeof(Grid));
|
||||||
|
var face = new FrameworkElementFactory(typeof(Border)) { Name = "face" };
|
||||||
|
face.SetBinding(Border.BackgroundProperty, new Binding("Background") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
|
||||||
|
face.SetBinding(Border.PaddingProperty, new Binding("Padding") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
|
||||||
|
var cp = new FrameworkElementFactory(typeof(ContentPresenter));
|
||||||
|
cp.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center);
|
||||||
|
cp.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center);
|
||||||
|
face.AppendChild(cp);
|
||||||
|
grid.AppendChild(face);
|
||||||
|
var light = new FrameworkElementFactory(typeof(Border)) { Name = "light" };
|
||||||
|
light.SetResourceReference(Border.BorderBrushProperty, "BevelLightBrush");
|
||||||
|
light.SetResourceReference(Border.BorderThicknessProperty, "ButtonBevelLightThickness");
|
||||||
|
grid.AppendChild(light);
|
||||||
|
var dark = new FrameworkElementFactory(typeof(Border)) { Name = "dark" };
|
||||||
|
dark.SetResourceReference(Border.BorderBrushProperty, "BevelDarkBrush");
|
||||||
|
dark.SetResourceReference(Border.BorderThicknessProperty, "ButtonBevelDarkThickness");
|
||||||
|
grid.AppendChild(dark);
|
||||||
|
var template = new ControlTemplate(typeof(Button)) { VisualTree = grid };
|
||||||
|
var pressed = new Trigger { Property = Button.IsPressedProperty, Value = true };
|
||||||
|
pressed.Setters.Add(new Setter(Border.BorderBrushProperty, Brush("BevelDarkBrush"), "light"));
|
||||||
|
pressed.Setters.Add(new Setter(Border.BorderBrushProperty, Brush("BevelLightBrush"), "dark"));
|
||||||
|
template.Triggers.Add(pressed);
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit-color overload for pre-theme windows (startup/crash/About). border==null = borderless.
|
||||||
|
public static Button Make(object content, Brush normalBg, Brush hoverBg, Brush normalFg, Brush hoverFg, Brush? border = null)
|
||||||
|
{
|
||||||
|
var btn = new Button
|
||||||
|
{
|
||||||
|
Content = content,
|
||||||
|
Padding = new Thickness(18, 6, 18, 6),
|
||||||
|
Background = normalBg,
|
||||||
|
Foreground = normalFg,
|
||||||
|
BorderBrush = border ?? Brushes.Transparent,
|
||||||
|
BorderThickness = new Thickness(border == null ? 0 : 1),
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
FontFamily = UiFont,
|
||||||
|
FontSize = 12,
|
||||||
|
FocusVisualStyle = null,
|
||||||
|
Template = ButtonTemplate(),
|
||||||
|
};
|
||||||
|
btn.MouseEnter += (_, _) => { btn.Background = hoverBg; btn.Foreground = hoverFg; };
|
||||||
|
btn.MouseLeave += (_, _) => { btn.Background = normalBg; btn.Foreground = normalFg; };
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static ControlTemplate ButtonTemplate()
|
||||||
|
{
|
||||||
|
var bf = new FrameworkElementFactory(typeof(Border)) { Name = "bd" };
|
||||||
|
bf.SetBinding(Border.BackgroundProperty, new Binding("Background") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
|
||||||
|
bf.SetBinding(Border.BorderBrushProperty, new Binding("BorderBrush") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
|
||||||
|
bf.SetBinding(Border.BorderThicknessProperty, new Binding("BorderThickness") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
|
||||||
|
bf.SetBinding(Border.PaddingProperty, new Binding("Padding") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
|
||||||
|
bf.SetValue(Border.CornerRadiusProperty, RadControl);
|
||||||
|
|
||||||
|
var cp = new FrameworkElementFactory(typeof(ContentPresenter));
|
||||||
|
cp.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center);
|
||||||
|
cp.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center);
|
||||||
|
bf.AppendChild(cp);
|
||||||
|
var ct = new ControlTemplate(typeof(Button)) { VisualTree = bf };
|
||||||
|
var dis = new Trigger { Property = UIElement.IsEnabledProperty, Value = false };
|
||||||
|
dis.Setters.Add(new Setter(UIElement.OpacityProperty, 0.45) { TargetName = "bd" });
|
||||||
|
ct.Triggers.Add(dis);
|
||||||
|
return ct;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using PdfSharpCore.Pdf;
|
||||||
|
|
||||||
|
namespace KillerPDF.Controls
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The viewer's outward surface: what the window still calls into.
|
||||||
|
///
|
||||||
|
/// WHY A FACADE RATHER THAN WIDENING. Roughly 55 of these are private members of the seven
|
||||||
|
/// moved files. Making them internal in place would mean 55 edits scattered through code that
|
||||||
|
/// is otherwise VERBATIM - and "verbatim" is the property that makes the move reviewable by
|
||||||
|
/// diff. This file is part of the same partial class, so it can see those privates and
|
||||||
|
/// re-expose them without touching a line of the moved code.
|
||||||
|
///
|
||||||
|
/// The `Ext` suffix exists only because a wrapper cannot share a name with the member it wraps
|
||||||
|
/// inside one class. Members that were already internal (RenderAllAnnotations, ClearSelection,
|
||||||
|
/// ClearTextSelection, AccentBrush) are absent here - the window calls those directly.
|
||||||
|
///
|
||||||
|
/// Every entry is one call site away from deletion: when a caller moves into the viewer, its
|
||||||
|
/// line here goes with it.
|
||||||
|
/// </summary>
|
||||||
|
public partial class PdfViewer
|
||||||
|
{
|
||||||
|
// ── Annotations: selection, hit-testing, geometry ────────────────────────────────────
|
||||||
|
internal void AddAnnotationExt(PageAnnotation a) => AddAnnotation(a);
|
||||||
|
internal Rect AnnotBoundsExt(PageAnnotation a) => AnnotBounds(a);
|
||||||
|
internal static Point AnnotGetPosExt(PageAnnotation a) => AnnotGetPos(a);
|
||||||
|
internal static void AnnotSetPosExt(PageAnnotation a, Point pos) => AnnotSetPos(a, pos);
|
||||||
|
internal Point ClampAnnotPosExt(PageAnnotation a) => ClampAnnotPos(a);
|
||||||
|
internal bool HitTestAnnotationExt(PageAnnotation a, Point pos, out Rect bounds)
|
||||||
|
=> HitTestAnnotation(a, pos, out bounds);
|
||||||
|
internal static bool IsDraggableExt(PageAnnotation a) => IsDraggable(a);
|
||||||
|
internal void SelectAnnotationExt(PageAnnotation a, Rect bounds) => SelectAnnotation(a, bounds);
|
||||||
|
internal void ToggleMultiSelectExt(PageAnnotation a, Rect bounds, Canvas canvas)
|
||||||
|
=> ToggleMultiSelect(a, bounds, canvas);
|
||||||
|
internal void SelectGroupExt(PageAnnotation lead) => SelectGroup(lead);
|
||||||
|
internal PageAnnotation? SelectedPairedExt() => SelectedPaired();
|
||||||
|
internal int SelectionCountExt() => SelectionCount();
|
||||||
|
internal void ReattachSelectionVisualsExt() => ReattachSelectionVisuals();
|
||||||
|
internal void UnpairSelectedExt() => UnpairSelected();
|
||||||
|
internal void GroupSelectedExt() => GroupSelected();
|
||||||
|
internal void UngroupAnnotationExt(PageAnnotation a) => UngroupAnnotation(a);
|
||||||
|
internal void RemoveFromGroupExt(PageAnnotation a) => RemoveFromGroup(a);
|
||||||
|
internal void DeleteSelectedExt() => DeleteSelected();
|
||||||
|
internal bool SelectAllAnnotationsExt() => SelectAllAnnotations();
|
||||||
|
internal void HideBrushPreviewExt() => HideBrushPreview();
|
||||||
|
internal void FinishStuckGestureExt() => FinishStuckGesture();
|
||||||
|
internal void RefreshSelectionAccentExt() => RefreshSelectionAccent();
|
||||||
|
|
||||||
|
// ── Page canvases ────────────────────────────────────────────────────────────────────
|
||||||
|
internal Canvas CanvasForPageExt(int page) => CanvasForPage(page);
|
||||||
|
internal Canvas? VisibleCanvasForPageExt(int page) => VisibleCanvasForPage(page);
|
||||||
|
internal IEnumerable<Canvas> AllPageCanvasesExt() => AllPageCanvases();
|
||||||
|
|
||||||
|
// ── Undo / commands bound from MainWindow.xaml and the context menu ──────────────────
|
||||||
|
internal void PushDocUndoExt() => PushDocUndo();
|
||||||
|
internal void PushPageSnapshotUndoExt(int pageIdx) => PushPageSnapshotUndo(pageIdx);
|
||||||
|
internal void UndoClickExt(object sender, RoutedEventArgs e) => Undo_Click(sender, e);
|
||||||
|
internal void RedoClickExt(object sender, RoutedEventArgs e) => Redo_Click(sender, e);
|
||||||
|
internal void ClearAnnotationsClickExt(object sender, RoutedEventArgs e) => ClearAnnotations_Click(sender, e);
|
||||||
|
internal void ClearAllAnnotationsClickExt(object sender, RoutedEventArgs e) => ClearAllAnnotations_Click(sender, e);
|
||||||
|
|
||||||
|
// ── Text editing ─────────────────────────────────────────────────────────────────────
|
||||||
|
internal void CommitActiveTextBoxExt() => CommitActiveTextBox();
|
||||||
|
internal void RemoveTextEditHandlesExt() => RemoveTextEditHandles();
|
||||||
|
internal void EditTextAtPositionExt(Point canvasPos, int pageIdx) => EditTextAtPosition(canvasPos, pageIdx);
|
||||||
|
internal void PlaceTextBoxExt(Point pos, int pageIdx) => PlaceTextBox(pos, pageIdx);
|
||||||
|
internal Brush TextEditBackgroundExt() => TextEditBackground();
|
||||||
|
internal static ControlTemplate FlatTextBoxTemplateExt() => FlatTextBoxTemplate();
|
||||||
|
|
||||||
|
// ── Text selection ───────────────────────────────────────────────────────────────────
|
||||||
|
internal void CopySelectedTextExt() => CopySelectedText();
|
||||||
|
internal void SelectAllTextExt() => SelectAllText();
|
||||||
|
|
||||||
|
// ── Crop ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
internal void ApplyCropExt(int[] pageIndices) => ApplyCrop(pageIndices);
|
||||||
|
internal void HideCropConfirmBarExt() => HideCropConfirmBar();
|
||||||
|
internal void ShowDefaultCropBoxExt() => ShowDefaultCropBox();
|
||||||
|
internal void RebuildCropBarForLocaleExt() => RebuildCropBarForLocale();
|
||||||
|
|
||||||
|
// ── Links ────────────────────────────────────────────────────────────────────────────
|
||||||
|
internal void CloseLinkPdfiumDocExt() => CloseLinkPdfiumDoc();
|
||||||
|
internal void AddLinkMenuItemsExt(ContextMenu menu, object target, int annotIndex, int pageIndex)
|
||||||
|
=> AddLinkMenuItems(menu, target, annotIndex, pageIndex);
|
||||||
|
internal int? ResolveDestExt(PdfItem? destItem) => ResolveDest(destItem);
|
||||||
|
internal bool IsPanning => _isPanning;
|
||||||
|
internal EditTool CurrentToolRef { get => _currentTool; set => _currentTool = value; }
|
||||||
|
internal PdfDocument? DocumentRef { get => _doc; set => _doc = value; }
|
||||||
|
internal string? CurrentFileRef { get => _currentFile; set => _currentFile = value; }
|
||||||
|
internal Dictionary<int, List<PageAnnotation>> AnnotationsRef { get => _annotations; set => _annotations = value; }
|
||||||
|
internal Dictionary<int, (int w, int h)> RenderDimsRef { get => _renderDims; set => _renderDims = value; }
|
||||||
|
internal Dictionary<int, int> PageRotationsRef { get => _pageRotations; set => _pageRotations = value; }
|
||||||
|
internal bool IsDrawingRef { get => _isDrawing; set => _isDrawing = value; }
|
||||||
|
internal Point DrawStartRef { get => _drawStart; set => _drawStart = value; }
|
||||||
|
internal UIElement? ActivePreviewRef { get => _activePreview; set => _activePreview = value; }
|
||||||
|
internal System.Windows.Shapes.Rectangle? CropPreviewRectRef { get => _cropPreviewRect; set => _cropPreviewRect = value; }
|
||||||
|
internal Border? CropConfirmBarRef { get => _cropConfirmBar; set => _cropConfirmBar = value; }
|
||||||
|
internal PageAnnotation? SelectedAnnotationRef { get => _selectedAnnotation; set => _selectedAnnotation = value; }
|
||||||
|
internal Border? SelectionBorderRef { get => _selectionBorder; set => _selectionBorder = value; }
|
||||||
|
internal List<PageAnnotation> SelectedSetRef => _selectedSet;
|
||||||
|
internal List<Border> SelectionOutlinesRef => _selectionOutlines;
|
||||||
|
internal System.Windows.Shapes.Rectangle? PairedCoverOutlineRef { get => _pairedCoverOutline; set => _pairedCoverOutline = value; }
|
||||||
|
internal System.Windows.Shapes.Rectangle? ReeditCoverOutlineRef { get => _reeditCoverOutline; set => _reeditCoverOutline = value; }
|
||||||
|
internal string? SelectedTextRef { get => _selectedText; set => _selectedText = value; }
|
||||||
|
internal List<(PageAnnotation a, Point orig)> DragGroupOrigRef => _dragGroupOrig;
|
||||||
|
internal Color DrawColorRef { get => _drawColor; set => _drawColor = value; }
|
||||||
|
internal double DrawWidthRef { get => _drawWidth; set => _drawWidth = value; }
|
||||||
|
internal byte DrawOpacityRef { get => _drawOpacity; set => _drawOpacity = value; }
|
||||||
|
internal bool LineLevelRef { get => _lineLevel; set => _lineLevel = value; }
|
||||||
|
internal bool HighlightEraseRef { get => _highlightErase; set => _highlightErase = value; }
|
||||||
|
internal bool DrawEraseRef { get => _drawErase; set => _drawErase = value; }
|
||||||
|
internal Color HighlightColorRef { get => _highlightColor; set => _highlightColor = value; }
|
||||||
|
internal Color LineAnnotColorRef { get => _lineAnnotColor; set => _lineAnnotColor = value; }
|
||||||
|
internal InkAnnotation? ActiveInkRef { get => _activeInk; set => _activeInk = value; }
|
||||||
|
internal TextBox? ActiveTextBoxRef { get => _activeTextBox; set => _activeTextBox = value; }
|
||||||
|
internal double TextFontSizeRef { get => _textFontSize; set => _textFontSize = value; }
|
||||||
|
internal string TextFontNameRef { get => _textFontName; set => _textFontName = value; }
|
||||||
|
internal bool TextBoldRef { get => _textBold; set => _textBold = value; }
|
||||||
|
internal bool TextItalicRef { get => _textItalic; set => _textItalic = value; }
|
||||||
|
internal bool TextStrikeRef { get => _textStrike; set => _textStrike = value; }
|
||||||
|
internal bool TextUnderlineRef { get => _textUnderline; set => _textUnderline = value; }
|
||||||
|
internal Color TextColorRef { get => _textColor; set => _textColor = value; }
|
||||||
|
internal byte TextOpacityRef { get => _textOpacity; set => _textOpacity = value; }
|
||||||
|
internal Color TextFillColorRef { get => _textFillColor; set => _textFillColor = value; }
|
||||||
|
internal TextAnnotation? ReeditOriginalRef { get => _reeditOriginal; set => _reeditOriginal = value; }
|
||||||
|
internal CoverAnnotation? PendingCoverRef { get => _pendingCover; set => _pendingCover = value; }
|
||||||
|
internal bool PendingEditWasDirtyRef { get => _pendingEditWasDirty; set => _pendingEditWasDirty = value; }
|
||||||
|
internal Border? TextSettingsBarRef { get => _textSettingsBar; set => _textSettingsBar = value; }
|
||||||
|
internal bool IsResizingSigRef { get => _isResizingSig; set => _isResizingSig = value; }
|
||||||
|
internal Point ResizeSigStartRef { get => _resizeSigStart; set => _resizeSigStart = value; }
|
||||||
|
internal double ResizeSigStartScaleRef { get => _resizeSigStartScale; set => _resizeSigStartScale = value; }
|
||||||
|
internal PlacedAnnotation? ResizeSigAnnotRef { get => _resizeSigAnnot; set => _resizeSigAnnot = value; }
|
||||||
|
internal TextAnnotation? ResizeTextAnnotRef { get => _resizeTextAnnot; set => _resizeTextAnnot = value; }
|
||||||
|
internal HighlightAnnotation? ResizeHlAnnotRef { get => _resizeHlAnnot; set => _resizeHlAnnot = value; }
|
||||||
|
internal InkAnnotation? ResizeInkAnnotRef { get => _resizeInkAnnot; set => _resizeInkAnnot = value; }
|
||||||
|
internal List<Point>? ResizeInkOrigPointsRef { get => _resizeInkOrigPoints; set => _resizeInkOrigPoints = value; }
|
||||||
|
internal Rect ResizeInkOrigBoundsRef { get => _resizeInkOrigBounds; set => _resizeInkOrigBounds = value; }
|
||||||
|
internal List<System.Windows.Shapes.Rectangle> ResizeHandlesRef => _resizeHandles;
|
||||||
|
internal string ResizeCornerRef { get => _resizeCorner; set => _resizeCorner = value; }
|
||||||
|
internal Point ResizeAnchorRef { get => _resizeAnchor; set => _resizeAnchor = value; }
|
||||||
|
internal List<System.Windows.Shapes.Rectangle> TextEditHandlesRef => _textEditHandles;
|
||||||
|
internal bool DraggingTextEditHandleRef { get => _draggingTextEditHandle; set => _draggingTextEditHandle = value; }
|
||||||
|
internal string TehCornerRef { get => _tehCorner; set => _tehCorner = value; }
|
||||||
|
internal Point TehAnchorRef { get => _tehAnchor; set => _tehAnchor = value; }
|
||||||
|
internal TextBox? TehBoxRef { get => _tehBox; set => _tehBox = value; }
|
||||||
|
internal bool IsDraggingAnnotRef { get => _isDraggingAnnot; set => _isDraggingAnnot = value; }
|
||||||
|
internal Point DragAnnotStartRef { get => _dragAnnotStart; set => _dragAnnotStart = value; }
|
||||||
|
internal Point DragAnnotOrigPosRef { get => _dragAnnotOrigPos; set => _dragAnnotOrigPos = value; }
|
||||||
|
internal PageAnnotation? DragAnnotRef { get => _dragAnnot; set => _dragAnnot = value; }
|
||||||
|
internal Rect CropCanvasRectRef { get => _cropCanvasRect; set => _cropCanvasRect = value; }
|
||||||
|
internal System.Windows.Shapes.Rectangle? CropPreviewRectBorderRef { get => _cropPreviewRectBorder; set => _cropPreviewRectBorder = value; }
|
||||||
|
internal List<System.Windows.Shapes.Path> CropBracketsRef => _cropBrackets;
|
||||||
|
internal List<System.Windows.Shapes.Rectangle> CropHandlesRef => _cropHandles;
|
||||||
|
internal string? ActiveCropHandleTagRef { get => _activeCropHandleTag; set => _activeCropHandleTag = value; }
|
||||||
|
internal Point CropHandleDragStartRef { get => _cropHandleDragStart; set => _cropHandleDragStart = value; }
|
||||||
|
internal Rect CropRectAtHandleDragRef { get => _cropRectAtHandleDrag; set => _cropRectAtHandleDrag = value; }
|
||||||
|
internal TextBox? CropXBoxRef { get => _cropXBox; set => _cropXBox = value; }
|
||||||
|
internal TextBox? CropYBoxRef { get => _cropYBox; set => _cropYBox = value; }
|
||||||
|
internal TextBox? CropWBoxRef { get => _cropWBox; set => _cropWBox = value; }
|
||||||
|
internal TextBox? CropHBoxRef { get => _cropHBox; set => _cropHBox = value; }
|
||||||
|
internal TextBox? CropRangeBoxRef { get => _cropRangeBox; set => _cropRangeBox = value; }
|
||||||
|
internal string CropUnitRef { get => _cropUnit; set => _cropUnit = value; }
|
||||||
|
internal bool UpdatingCropInputsRef { get => _updatingCropInputs; set => _updatingCropInputs = value; }
|
||||||
|
internal Dictionary<int, string> FormTextValuesRef { get => _formTextValues; set => _formTextValues = value; }
|
||||||
|
internal Dictionary<int, bool> FormCheckValuesRef { get => _formCheckValues; set => _formCheckValues = value; }
|
||||||
|
internal Dictionary<string, string> FormRadioValuesRef { get => _formRadioValues; set => _formRadioValues = value; }
|
||||||
|
internal Dictionary<int, double> FormFontSizesRef { get => _formFontSizes; set => _formFontSizes = value; }
|
||||||
|
internal Border? FormSizeBarRef { get => _formSizeBar; set => _formSizeBar = value; }
|
||||||
|
internal TextBox? ActiveFormTbRef { get => _activeFormTb; set => _activeFormTb = value; }
|
||||||
|
internal int ActiveFormObjRef { get => _activeFormObj; set => _activeFormObj = value; }
|
||||||
|
internal double ActiveFormScaleRef { get => _activeFormScale; set => _activeFormScale = value; }
|
||||||
|
internal Stack<UndoEntry> UndoStackRef { get => _undoStack; set => _undoStack = value; }
|
||||||
|
internal Stack<UndoEntry> RedoStackRef { get => _redoStack; set => _redoStack = value; }
|
||||||
|
internal bool IsDirtyRef { get => _isDirty; set => _isDirty = value; }
|
||||||
|
internal string? OriginalFileRef { get => _originalFile; set => _originalFile = value; }
|
||||||
|
internal bool OpenedFromProtectedRef { get => _openedFromProtected; set => _openedFromProtected = value; }
|
||||||
|
internal bool AsyncOpenPendingRef { get => _asyncOpenPending; set => _asyncOpenPending = value; }
|
||||||
|
internal Stack<int> NavBackRef => _navBack;
|
||||||
|
internal Stack<int> NavForwardRef => _navForward;
|
||||||
|
internal bool OcrRegionModeRef { get => _ocrRegionMode; set => _ocrRegionMode = value; }
|
||||||
|
internal SavedSignature? PendingSignatureRef { get => _pendingSignature; set => _pendingSignature = value; }
|
||||||
|
internal List<Point> ShapePolyPointsRef => _shapePolyPoints;
|
||||||
|
internal EditTool? AnnotBarToolRef { get => _annotBarTool; set => _annotBarTool = value; }
|
||||||
|
internal bool AnnotBarMinimizedRef { get => _annotBarMinimized; set => _annotBarMinimized = value; }
|
||||||
|
internal List<FrameworkElement> AnnotBarDragInnersRef => _annotBarDragInners;
|
||||||
|
|
||||||
|
// ── Save paths ───────────────────────────────────────────────────────────────────────
|
||||||
|
internal void DrawAnnotationsOnDocumentExt(int? onlyPage = null) => DrawAnnotationsOnDocument(onlyPage);
|
||||||
|
internal void WriteFormValuesToDocumentExt() => WriteFormValuesToDocument();
|
||||||
|
|
||||||
|
// ── Handlers bound from MainWindow.xaml ──────────────────────────────────────────────
|
||||||
|
// WPF resolves Click="X" against the XAML root's code-behind, which is still MainWindow, so
|
||||||
|
// these keep working only because MainWindowViewerStubs.cs re-declares each name and points
|
||||||
|
// it here. Moving them without that would throw XamlParseException at startup.
|
||||||
|
internal void PageJumpBoxKeyDownExt(object sender, KeyEventArgs e) => PageJumpBox_KeyDown(sender, e);
|
||||||
|
internal void PageJumpBoxGotFocusExt(object sender, RoutedEventArgs e) => PageJumpBox_GotFocus(sender, e);
|
||||||
|
internal void PageListSelectionChangedExt(object sender, SelectionChangedEventArgs e)
|
||||||
|
=> PageList_SelectionChanged(sender, e);
|
||||||
|
internal void ShortcutHelpClickExt(object sender, RoutedEventArgs e) => ShortcutHelp_Click(sender, e);
|
||||||
|
internal void ShortcutOverlayMouseDownExt(object sender, MouseButtonEventArgs e)
|
||||||
|
=> ShortcutOverlay_MouseLeftButtonDown(sender, e);
|
||||||
|
internal void ShortcutOverlayCardMouseDownExt(object sender, MouseButtonEventArgs e)
|
||||||
|
=> ShortcutOverlayCard_MouseLeftButtonDown(sender, e);
|
||||||
|
internal void ShortcutOverlayCloseClickExt(object sender, RoutedEventArgs e)
|
||||||
|
=> ShortcutOverlayClose_Click(sender, e);
|
||||||
|
internal void HyperlinkRequestNavigateExt(object sender, System.Windows.Navigation.RequestNavigateEventArgs e)
|
||||||
|
=> Hyperlink_RequestNavigate(sender, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Shapes;
|
||||||
|
using PdfSharpCore.Pdf;
|
||||||
|
|
||||||
|
namespace KillerPDF.Controls
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Transitional forwards used by the moved render pipeline while the remaining bridge surface
|
||||||
|
/// is converted to IViewerHost and per-document state.
|
||||||
|
///
|
||||||
|
/// WHY THIS FILE EXISTS. PdfViewer.Viewport.cs and PdfViewer.Zoom.cs were moved across VERBATIM -
|
||||||
|
/// roughly 2,100 lines carrying about 700 references to window members spelled bare (PageList,
|
||||||
|
/// _doc, Loc, RenderAllAnnotations...). Rewriting those 700 sites in the same change that moved
|
||||||
|
/// the files would have been unreviewable. Declaring the names here instead means the moved
|
||||||
|
/// files did not change a character of logic, and the entire coupling surface between viewer
|
||||||
|
/// and window is one readable list.
|
||||||
|
///
|
||||||
|
/// THIS IS SCAFFOLDING, NOT THE DESTINATION. Read the groups below as a to-do list:
|
||||||
|
/// - Group B is per-DOCUMENT state that belongs in DocumentSession. When the viewer holds
|
||||||
|
/// its own active session, that block deletes itself.
|
||||||
|
/// - Group C members live in files that have not moved into the viewer. Each deletes itself
|
||||||
|
/// as its defining file arrives; the defining file is named against every one.
|
||||||
|
/// - Group A is the only group meant to survive, and it should end up expressed as
|
||||||
|
/// IViewerHost rather than as raw Owner reach.
|
||||||
|
///
|
||||||
|
/// Host is null only between construction and the window wiring it up, which happens in the
|
||||||
|
/// MainWindow constructor before any of this can run. The null-forgiving operator is therefore
|
||||||
|
/// deliberate: a null here is a wiring bug and should throw loudly, not render nothing.
|
||||||
|
/// </summary>
|
||||||
|
public partial class PdfViewer
|
||||||
|
{
|
||||||
|
// ── The view's own state ─────────────────────────────────────────────────────────────
|
||||||
|
// Not forwards: this viewer OWNS its ViewerState (see PdfViewer.xaml.cs). These mirror the
|
||||||
|
// window's forwarding properties one for one, so the moved code reads identically.
|
||||||
|
private ViewMode _viewMode { get => State.Mode; set => State.Mode = value; }
|
||||||
|
private ViewMode? _pendingViewMode { get => State.Pending; set => State.Pending = value; }
|
||||||
|
private double _zoomLevel { get => State.ZoomLevel; set => State.ZoomLevel = value; }
|
||||||
|
private double _lastRenderZoom { get => State.LastRenderZoom; set => State.LastRenderZoom = value; }
|
||||||
|
private int _renderedPrimaryPage { get => State.RenderedPrimaryPage; set => State.RenderedPrimaryPage = value; }
|
||||||
|
private FitMode _fitMode { get => State.Fit; set => State.Fit = value; }
|
||||||
|
private System.Windows.Threading.DispatcherTimer? _rerenderTimer { get => State.RerenderTimer; set => State.RerenderTimer = value; }
|
||||||
|
private System.Threading.CancellationTokenSource? _secondaryRenderCts { get => State.SecondaryRenderCts; set => State.SecondaryRenderCts = value; }
|
||||||
|
private System.Threading.CancellationTokenSource? _continuousRenderCts { get => State.ContinuousRenderCts; set => State.ContinuousRenderCts = value; }
|
||||||
|
private System.Threading.CancellationTokenSource? _continuousSharpenCts { get => State.ContinuousSharpenCts; set => State.ContinuousSharpenCts = value; }
|
||||||
|
private HashSet<int> _continuousSharpPages => State.ContinuousSharpPages;
|
||||||
|
private int _continuousSharpW { get => State.ContinuousSharpW; set => State.ContinuousSharpW = value; }
|
||||||
|
private List<double> _continuousTops => State.ContinuousTops;
|
||||||
|
private int _gridScrollToPage { get => State.GridScrollToPage; set => State.GridScrollToPage = value; }
|
||||||
|
private int _continuousScrollTarget { get => State.ContinuousScrollTarget; set => State.ContinuousScrollTarget = value; }
|
||||||
|
private double _continuousPageW { get => State.ContinuousPageW; set => State.ContinuousPageW = value; }
|
||||||
|
private Dictionary<int, Canvas> _pages => State.Pages;
|
||||||
|
private Dictionary<int, Canvas> _continuousCanvases => State.ContinuousCanvases;
|
||||||
|
private Canvas _annotationCanvas { get => State.AnnotationCanvas; set => State.AnnotationCanvas = value; }
|
||||||
|
private Canvas _activeCanvas { get => State.ActiveCanvas; set => State.ActiveCanvas = value; }
|
||||||
|
private Canvas? _gestureCanvas { get => State.GestureCanvas; set => State.GestureCanvas = value; }
|
||||||
|
private int _gesturePage { get => State.GesturePage; set => State.GesturePage = value; }
|
||||||
|
private Image PageImage { get => State.PageImage; set => State.PageImage = value; }
|
||||||
|
private StackPanel _continuousPanel { get => State.ContinuousPanel; set => State.ContinuousPanel = value; }
|
||||||
|
private WrapPanel _pageContentPanel { get => State.PageContentPanel; set => State.PageContentPanel = value; }
|
||||||
|
private Grid _pageContentGrid { get => State.PageContentGrid; set => State.PageContentGrid = value; }
|
||||||
|
private int _currentPage
|
||||||
|
{
|
||||||
|
get => State.CurrentPage;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
State.CurrentPage = value;
|
||||||
|
Host?.ViewerPageChanged(this, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Group A: host chrome and services ────────────────────────────────────────────────
|
||||||
|
// One toolbar, one sidebar, one status line serving both panes. These are the forwards
|
||||||
|
// meant to survive, and they should become direct IViewerHost calls.
|
||||||
|
|
||||||
|
private string Loc(string key) => Host!.Loc(key);
|
||||||
|
private void SetStatus(string text) => Host!.SetStatus(text);
|
||||||
|
private void RepositionAnnotationBars() => Host!.RepositionAnnotationBars();
|
||||||
|
|
||||||
|
private EditTool _currentTool = EditTool.Select;
|
||||||
|
private bool _fullScreen => Host!.FullScreen;
|
||||||
|
private bool _vScrollVisible { get => Host!.VerticalScrollVisible; set => Host!.VerticalScrollVisible = value; }
|
||||||
|
private bool _spaceHeld => Host!.SpaceHeld;
|
||||||
|
|
||||||
|
// Zoom limits stay defined on the window: MainWindow.xaml.cs and KeyboardShortcuts.cs read
|
||||||
|
// them too, and a const aliases at no cost rather than being duplicated.
|
||||||
|
private const double ZoomMin = MainWindow.ZoomMin;
|
||||||
|
private const double ZoomMax = MainWindow.ZoomMax;
|
||||||
|
private const double ZoomStep = MainWindow.ZoomStep;
|
||||||
|
|
||||||
|
// ── Group B: per-document state ──────────────────────────────────────────────────────
|
||||||
|
// NOT host services. Every one of these already rides in DocumentSession, which tab
|
||||||
|
// switching swaps by reference. They forward for now because the window still owns the
|
||||||
|
// active session; when the viewer holds its own, this whole block goes.
|
||||||
|
private PdfDocument? _doc;
|
||||||
|
private string? _currentFile;
|
||||||
|
// Settable: the tab switch rebinds all three by reference.
|
||||||
|
private Dictionary<int, List<PageAnnotation>> _annotations = [];
|
||||||
|
private Dictionary<int, (int w, int h)> _renderDims = [];
|
||||||
|
private Dictionary<int, int> _pageRotations = [];
|
||||||
|
// _active is NOT forwarded: the session list lives in this class, so this pane owns its own
|
||||||
|
// active document. The window reads it back via ActiveSession.
|
||||||
|
private readonly List<Canvas> _linkOverlays = [];
|
||||||
|
// _continuousLinks is no longer forwarded - the field itself arrived with Links.cs and the
|
||||||
|
// viewer owns it now. ContextMenu.cs and FileOperations.cs read it from the window side
|
||||||
|
// through MainWindowViewerBridge's ContinuousLinksRef, which points back here.
|
||||||
|
|
||||||
|
// Live gesture state shared with the annotation and crop tools, which have not moved yet.
|
||||||
|
private bool _isPanning;
|
||||||
|
private Point _panStart;
|
||||||
|
private double _panScrollH;
|
||||||
|
private double _panScrollV;
|
||||||
|
private bool _isDrawing;
|
||||||
|
private Point _drawStart;
|
||||||
|
private UIElement? _activePreview;
|
||||||
|
private bool _isSelecting;
|
||||||
|
private Point _selectStart;
|
||||||
|
private Rectangle? _selectRect;
|
||||||
|
private int _cropPageIndex = -1;
|
||||||
|
// Crop.cs and Annotations.cs ASSIGN both, so these go through the settable pair on the
|
||||||
|
// window side rather than a get-only forward.
|
||||||
|
private Rectangle? _cropPreviewRect;
|
||||||
|
private Border? _cropConfirmBar;
|
||||||
|
|
||||||
|
// ── Group C: methods in partials that have NOT moved yet ─────────────────────────────
|
||||||
|
// Only four are left - the ones whose defining files stay on the window. RenderAllAnnotations,
|
||||||
|
// ClearSelection, UpdateMarquee, IsDescendantOf, the four Canvas_Mouse* handlers,
|
||||||
|
// ClearTextSelection, AccentBrush, RenderPageLinks, AddSecondaryPageLinks and the
|
||||||
|
// PageList_SelectionChanged delegate are real members of this class now.
|
||||||
|
private void PopulateContextMenu(Point pt, int page) => Host!.PopulateContextMenu(this, pt, page);
|
||||||
|
private void RefreshPageList() => Host!.RefreshPageList(this);
|
||||||
|
private void LoadOutlines() => Host!.LoadOutlines(this);
|
||||||
|
private Cursor CursorForTool(EditTool t) => Host!.CursorForTool(t);
|
||||||
|
|
||||||
|
// The render cache is not forwarded either - TryGetCachedRender / CacheRender are real
|
||||||
|
// members of this class. They work per-pane unchanged: the cache is keyed
|
||||||
|
// (page, bucket, rot) and both accessors take the session explicitly, so
|
||||||
|
// two panes at different zooms simply occupy different buckets of their own session's cache.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Shapes;
|
||||||
|
|
||||||
|
namespace KillerPDF.Controls
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Editing state and narrow shell-facing adapters owned by each viewer instance. Annotations,
|
||||||
|
/// text editing, crop, forms, links, selection and the current tool remain independent between
|
||||||
|
/// panes; only window chrome is routed through the host.
|
||||||
|
/// </summary>
|
||||||
|
public partial class PdfViewer
|
||||||
|
{
|
||||||
|
// ── Selection ────────────────────────────────────────────────────────────────────────
|
||||||
|
private PageAnnotation? _selectedAnnotation;
|
||||||
|
private Border? _selectionBorder;
|
||||||
|
private readonly List<PageAnnotation> _selectedSet = [];
|
||||||
|
private readonly List<Border> _selectionOutlines = [];
|
||||||
|
private Rectangle? _pairedCoverOutline;
|
||||||
|
private Rectangle? _reeditCoverOutline;
|
||||||
|
private string? _selectedText;
|
||||||
|
private readonly List<(PageAnnotation a, Point orig)> _dragGroupOrig = [];
|
||||||
|
|
||||||
|
// ── Draw / highlight tool ────────────────────────────────────────────────────────────
|
||||||
|
private Color _drawColor = Colors.Red;
|
||||||
|
private double _drawWidth = 3;
|
||||||
|
private byte _drawOpacity = 255;
|
||||||
|
private bool _lineLevel = true;
|
||||||
|
private bool _highlightErase;
|
||||||
|
private bool _drawErase;
|
||||||
|
private Color _highlightColor = Color.FromArgb(80, 255, 255, 0);
|
||||||
|
private Color _lineAnnotColor = Color.FromArgb(255, 220, 38, 38);
|
||||||
|
private InkAnnotation? _activeInk;
|
||||||
|
|
||||||
|
// ── Text (typewriter) tool ───────────────────────────────────────────────────────────
|
||||||
|
private TextBox? _activeTextBox;
|
||||||
|
private double _textFontSize = 24;
|
||||||
|
private string _textFontName = "Segoe UI";
|
||||||
|
private bool _textBold;
|
||||||
|
private bool _textItalic;
|
||||||
|
private bool _textStrike;
|
||||||
|
private bool _textUnderline;
|
||||||
|
private Color _textColor = Colors.Black;
|
||||||
|
private byte _textOpacity = 255;
|
||||||
|
private Color _textFillColor = Color.FromArgb(0, 255, 255, 255);
|
||||||
|
private TextAnnotation? _reeditOriginal;
|
||||||
|
private CoverAnnotation? _pendingCover;
|
||||||
|
private bool _pendingEditWasDirty;
|
||||||
|
private Border? _textSettingsBar;
|
||||||
|
private const double EditTextSizeCorrection = 0.8;
|
||||||
|
private const double TextBoxDefaultWidth = 220;
|
||||||
|
|
||||||
|
// ── Resize handles ───────────────────────────────────────────────────────────────────
|
||||||
|
private bool _isResizingSig;
|
||||||
|
private Point _resizeSigStart;
|
||||||
|
private double _resizeSigStartScale;
|
||||||
|
private PlacedAnnotation? _resizeSigAnnot;
|
||||||
|
private TextAnnotation? _resizeTextAnnot;
|
||||||
|
private HighlightAnnotation? _resizeHlAnnot;
|
||||||
|
private InkAnnotation? _resizeInkAnnot;
|
||||||
|
private List<Point>? _resizeInkOrigPoints;
|
||||||
|
private Rect _resizeInkOrigBounds;
|
||||||
|
private readonly List<Rectangle> _resizeHandles = [];
|
||||||
|
private string _resizeCorner = "SE";
|
||||||
|
private Point _resizeAnchor;
|
||||||
|
|
||||||
|
private readonly List<Rectangle> _textEditHandles = [];
|
||||||
|
private bool _draggingTextEditHandle;
|
||||||
|
private string _tehCorner = "SE";
|
||||||
|
private Point _tehAnchor;
|
||||||
|
private TextBox? _tehBox;
|
||||||
|
|
||||||
|
// ── Drag-to-move ─────────────────────────────────────────────────────────────────────
|
||||||
|
private bool _isDraggingAnnot;
|
||||||
|
private Point _dragAnnotStart;
|
||||||
|
private Point _dragAnnotOrigPos;
|
||||||
|
private PageAnnotation? _dragAnnot;
|
||||||
|
|
||||||
|
// ── Crop tool ────────────────────────────────────────────────────────────────────────
|
||||||
|
private Rect _cropCanvasRect;
|
||||||
|
private Rectangle? _cropPreviewRectBorder;
|
||||||
|
private readonly List<System.Windows.Shapes.Path> _cropBrackets = [];
|
||||||
|
private readonly List<Rectangle> _cropHandles = [];
|
||||||
|
private string? _activeCropHandleTag;
|
||||||
|
private Point _cropHandleDragStart;
|
||||||
|
private Rect _cropRectAtHandleDrag;
|
||||||
|
private TextBox? _cropXBox;
|
||||||
|
private TextBox? _cropYBox;
|
||||||
|
private TextBox? _cropWBox;
|
||||||
|
private TextBox? _cropHBox;
|
||||||
|
private TextBox? _cropRangeBox;
|
||||||
|
private string _cropUnit = "pt";
|
||||||
|
private bool _updatingCropInputs;
|
||||||
|
|
||||||
|
// ── Form filling ─────────────────────────────────────────────────────────────────────
|
||||||
|
private Dictionary<int, string> _formTextValues = [];
|
||||||
|
private Dictionary<int, bool> _formCheckValues = [];
|
||||||
|
private Dictionary<string, string> _formRadioValues = [];
|
||||||
|
private Dictionary<int, double> _formFontSizes = [];
|
||||||
|
private Border? _formSizeBar;
|
||||||
|
private TextBox? _activeFormTb;
|
||||||
|
private int _activeFormObj;
|
||||||
|
private double _activeFormScale = 1;
|
||||||
|
private const string FormOverlayTag = "FormFieldOverlay";
|
||||||
|
|
||||||
|
// ── Undo / dirty ─────────────────────────────────────────────────────────────────────
|
||||||
|
private Stack<UndoEntry> _undoStack = new();
|
||||||
|
private Stack<UndoEntry> _redoStack = new();
|
||||||
|
private bool _isDirty;
|
||||||
|
|
||||||
|
// ── State owned by files that did not move ───────────────────────────────────────────
|
||||||
|
private Border? _searchBar => Host!.SearchBar;
|
||||||
|
private Features.SearchController Search => Host!.Search;
|
||||||
|
private bool _ocrRegionMode;
|
||||||
|
private SavedSignature? _pendingSignature;
|
||||||
|
private readonly List<Point> _shapePolyPoints = [];
|
||||||
|
private EditTool? _annotBarTool;
|
||||||
|
private bool _annotBarMinimized;
|
||||||
|
private readonly List<FrameworkElement> _annotBarDragInners = [];
|
||||||
|
private SolidColorBrush _swatchDimBorder => Host!.SwatchDimBorder;
|
||||||
|
|
||||||
|
// ══ What Tabs.cs reaches for, now that it lives here ════════════════════════════════
|
||||||
|
private string? _originalFile;
|
||||||
|
private bool _openedFromProtected;
|
||||||
|
private bool _asyncOpenPending;
|
||||||
|
// This pane's own loader token, NOT the window's - see ThumbCts in PdfViewer.TabsApi.cs.
|
||||||
|
private System.Threading.CancellationTokenSource? _thumbCts { get => ThumbCts; set => ThumbCts = value; }
|
||||||
|
private bool _sidebarShowingOutlines => Host!.SidebarShowingOutlines;
|
||||||
|
private readonly System.Collections.Generic.Stack<int> _navBack = new();
|
||||||
|
private readonly System.Collections.Generic.Stack<int> _navForward = new();
|
||||||
|
|
||||||
|
private TextBlock FileNameLabel => Host!.FileNameLabel;
|
||||||
|
private TreeView OutlineTree => Host!.OutlineTree;
|
||||||
|
private Button SidebarOutlinesTab => Host!.SidebarOutlinesTab;
|
||||||
|
|
||||||
|
private ContextMenu MakeThemedMenu() => Host!.MakeThemedMenu();
|
||||||
|
private void CloseSearchBar() => Host!.CloseSearchBar();
|
||||||
|
private void HideSignaturePopup() => Host!.HideSignaturePopup();
|
||||||
|
private void PopulateRecentFilesList() => Host!.PopulateRecentFilesList(this);
|
||||||
|
private void SwitchSidebarToPagesTab() => Host!.SwitchSidebarToPagesTab();
|
||||||
|
private void SyncSidebarToDocState(bool hasDoc, bool startup) => Host!.SyncSidebarToDocState(hasDoc, startup);
|
||||||
|
private void OpenFile(string path) => Host!.OpenFile(path);
|
||||||
|
private void UpdateFooterFade() => Host!.UpdateFooterFade();
|
||||||
|
private void UpdateTabStripFade() => Host!.UpdateTabStripFade();
|
||||||
|
|
||||||
|
// ── Chrome ───────────────────────────────────────────────────────────────────────────
|
||||||
|
private TextBlock StatusText => Host!.StatusText;
|
||||||
|
private FrameworkElement ShortcutOverlay => Host!.ShortcutOverlay;
|
||||||
|
private CheckBox LinkConfirmCheck => Host!.LinkConfirmCheck;
|
||||||
|
|
||||||
|
// ── Methods still on the window ──────────────────────────────────────────────────────
|
||||||
|
private void MarkDirty(bool dirty = true) => Host!.MarkDirty(dirty);
|
||||||
|
private void SetTool(EditTool t) => Host!.SetTool(t);
|
||||||
|
private void SaveTempAndReload(bool keepAnnotations = false, bool preserveZoom = false)
|
||||||
|
=> Host!.SaveTempAndReload(keepAnnotations, preserveZoom);
|
||||||
|
private void RecordNavJump() => Host!.RecordNavJump();
|
||||||
|
private PageAnnotation? CloneAnnotation(PageAnnotation a) => Host!.CloneAnnotation(a);
|
||||||
|
private PageAnnotation? PairPartner(PageAnnotation a) => Host!.PairPartner(a);
|
||||||
|
private void RenderStamps(int page) => Host!.RenderStamps(page);
|
||||||
|
private void OpenStampTool() => Host!.OpenStampTool();
|
||||||
|
private bool StampHitTest(int page, Point pos) => Host!.StampHitTest(page, pos);
|
||||||
|
private void ApplySearchHighlights(int page, Canvas canvas) => Host!.ApplySearchHighlights(page, canvas);
|
||||||
|
private void HighlightSearchResultsOnCurrentPage() => Host!.HighlightSearchResultsOnCurrentPage();
|
||||||
|
private void ShowTextSettings() => Host!.ShowTextSettings();
|
||||||
|
private void HideTextSettings() => Host!.HideTextSettings();
|
||||||
|
private void StyleEditBox(TextBox tb) => Host!.StyleEditBox(tb);
|
||||||
|
private void ApplyTextStyleToSelection() => Host!.ApplyTextStyleToSelection();
|
||||||
|
private TextDecorationCollection? BuildDecorations(bool underline, bool strike)
|
||||||
|
=> Host!.BuildDecorations(underline, strike);
|
||||||
|
private void ShowDrawSettings(EditTool t) => Host!.ShowDrawSettings(t);
|
||||||
|
private void HideDrawSettings() => Host!.HideDrawSettings();
|
||||||
|
private Border MakeBarGrip(int dotCount = 3) => Host!.MakeBarGrip(dotCount);
|
||||||
|
private FrameworkElement BuildBarHost(FrameworkElement content) => Host!.BuildBarHost(content);
|
||||||
|
private void PlaceAnnotationBar(Border bar, Border grip, bool fadeIn = false)
|
||||||
|
=> Host!.PlaceAnnotationBar(bar, grip, fadeIn);
|
||||||
|
private System.Windows.Media.Effects.DropShadowEffect AnnotBarShadow() => Host!.AnnotBarShadow();
|
||||||
|
private void PlaceImageFromDialog(Point pos, int pageIdx) => Host!.PlaceImageFromDialog(pos, pageIdx);
|
||||||
|
private void PlaceSignature(Point pos, int pageIdx) => Host!.PlaceSignature(pos, pageIdx);
|
||||||
|
private void ShowSignaturePopup() => Host!.ShowSignaturePopup();
|
||||||
|
private void FillSignField(bool initials, int objNum, int pageIndex,
|
||||||
|
double x, double y, double w, double h)
|
||||||
|
=> Host!.FillSignField(initials, objNum, pageIndex, x, y, w, h);
|
||||||
|
private void ShapeToolMouseDown(int pageIdx, Point pos, MouseButtonEventArgs e)
|
||||||
|
=> Host!.ShapeToolMouseDown(pageIdx, pos, e);
|
||||||
|
private void CommitShapeDrag(int pageIdx) => Host!.CommitShapeDrag(pageIdx);
|
||||||
|
private void UpdateShapePolyRubber(MouseEventArgs e) => Host!.UpdateShapePolyRubber(e);
|
||||||
|
private void OcrRegion(int pageIdx, Rect canvasBounds) => Host!.OcrRegion(pageIdx, canvasBounds);
|
||||||
|
private void ShowShortcutsOverlayExclusive() => Host!.ShowShortcutsOverlayExclusive();
|
||||||
|
private void FadeOverlayOut(UIElement el) => Host!.FadeOverlayOut(el);
|
||||||
|
private void FadeOutAndRemoveBar(Border? bar) => Host!.FadeOutAndRemoveBar(bar);
|
||||||
|
private PdfSharpCore.Pdf.PdfItem DerefItem(PdfSharpCore.Pdf.PdfItem item) => Host!.DerefItem(item);
|
||||||
|
private string WordsToText(IEnumerable<UglyToad.PdfPig.Content.Word> src) => Host!.WordsToText(src);
|
||||||
|
private MenuItem MakeMenuItem(string header, RoutedEventHandler click,
|
||||||
|
string? gesture = null, string? glyph = null)
|
||||||
|
=> Host!.MakeMenuItem(header, click, gesture, glyph);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,702 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Animation;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using System.Windows.Shapes;
|
||||||
|
using Docnet.Core;
|
||||||
|
using Docnet.Core.Models;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using PdfSharpCore.Drawing;
|
||||||
|
using PdfSharpCore.Pdf;
|
||||||
|
using PdfSharpCore.Pdf.IO;
|
||||||
|
using KillerPDF.Services;
|
||||||
|
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
|
||||||
|
|
||||||
|
namespace KillerPDF.Controls
|
||||||
|
{
|
||||||
|
// Moved from Shell/Crop.cs; the namespace and class line are the only changes. Window members
|
||||||
|
// spelled bare here resolve through PdfViewer.Bridge.cs.
|
||||||
|
public partial class PdfViewer
|
||||||
|
{
|
||||||
|
// ============================================================
|
||||||
|
// Crop tool
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Crop coordinate helpers
|
||||||
|
//
|
||||||
|
// The rendered canvas already incorporates the user-applied rotation stored
|
||||||
|
// in _pageRotations. These helpers invert / apply the same transforms that
|
||||||
|
// the link-overlay code uses (lines ~1925-1957), so canvas<->PDF coords are
|
||||||
|
// consistent with how Docnet drew the bitmap.
|
||||||
|
//
|
||||||
|
// rot=0: canvas_x = native_x * cW/pW, canvas_y = (pH - native_y) * cH/pH
|
||||||
|
// rot=90: canvas_x = native_y * cW/pH, canvas_y = native_x * cH/pW
|
||||||
|
// rot=180: canvas_x = (pW - nx) * cW/pW, canvas_y = (pH - ny) * cH/pH
|
||||||
|
// rot=270: canvas_x = (pH - ny) * cW/pH, canvas_y = (pW - nx) * cH/pW
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convert a canvas-space <see cref="Rect"/> to PDF CropBox coordinates
|
||||||
|
/// (bottom-left origin, points) with rotation awareness.
|
||||||
|
/// </summary>
|
||||||
|
private static (double x1, double y1, double x2, double y2) CanvasToPdfRect(
|
||||||
|
Rect cr, double pdfW, double pdfH, double canvasW, double canvasH, int rot)
|
||||||
|
{
|
||||||
|
double cx = cr.X, cy = cr.Y, cw = cr.Width, ch = cr.Height;
|
||||||
|
return rot switch
|
||||||
|
{
|
||||||
|
90 => (cy * pdfW / canvasH,
|
||||||
|
cx * pdfH / canvasW,
|
||||||
|
(cy + ch) * pdfW / canvasH,
|
||||||
|
(cx + cw) * pdfH / canvasW),
|
||||||
|
|
||||||
|
180 => (pdfW - (cx + cw) * pdfW / canvasW,
|
||||||
|
pdfH - (cy + ch) * pdfH / canvasH,
|
||||||
|
pdfW - cx * pdfW / canvasW,
|
||||||
|
pdfH - cy * pdfH / canvasH),
|
||||||
|
|
||||||
|
270 => (pdfW - (cy + ch) * pdfW / canvasH,
|
||||||
|
pdfH - (cx + cw) * pdfH / canvasW,
|
||||||
|
pdfW - cy * pdfW / canvasH,
|
||||||
|
pdfH - cx * pdfH / canvasW),
|
||||||
|
|
||||||
|
_ => (cx * pdfW / canvasW, // 0 deg
|
||||||
|
pdfH - (cy + ch) * pdfH / canvasH,
|
||||||
|
(cx + cw) * pdfW / canvasW,
|
||||||
|
pdfH - cy * pdfH / canvasH),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inverse of <see cref="CanvasToPdfRect"/> - map PDF CropBox coords back to a canvas-space
|
||||||
|
/// <see cref="Rect"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static Rect PdfToCanvasRect(
|
||||||
|
double x1, double y1, double x2, double y2,
|
||||||
|
double pdfW, double pdfH, double canvasW, double canvasH, int rot)
|
||||||
|
{
|
||||||
|
double cx, cy, cw, ch;
|
||||||
|
switch (rot)
|
||||||
|
{
|
||||||
|
case 90:
|
||||||
|
cx = y1 * canvasW / pdfH;
|
||||||
|
cy = x1 * canvasH / pdfW;
|
||||||
|
cw = (y2 - y1) * canvasW / pdfH;
|
||||||
|
ch = (x2 - x1) * canvasH / pdfW;
|
||||||
|
break;
|
||||||
|
case 180:
|
||||||
|
cx = (pdfW - x2) * canvasW / pdfW;
|
||||||
|
cy = (pdfH - y2) * canvasH / pdfH;
|
||||||
|
cw = (x2 - x1) * canvasW / pdfW;
|
||||||
|
ch = (y2 - y1) * canvasH / pdfH;
|
||||||
|
break;
|
||||||
|
case 270:
|
||||||
|
cx = (pdfH - y2) * canvasW / pdfH;
|
||||||
|
cy = (pdfW - x2) * canvasH / pdfW;
|
||||||
|
cw = (y2 - y1) * canvasW / pdfH;
|
||||||
|
ch = (x2 - x1) * canvasH / pdfW;
|
||||||
|
break;
|
||||||
|
default: // 0 deg
|
||||||
|
cx = x1 * canvasW / pdfW;
|
||||||
|
cy = (pdfH - y2) * canvasH / pdfH;
|
||||||
|
cw = (x2 - x1) * canvasW / pdfW;
|
||||||
|
ch = (y2 - y1) * canvasH / pdfH;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return new Rect(Math.Max(0, cx), Math.Max(0, cy),
|
||||||
|
Math.Max(10, cw), Math.Max(10, ch));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The displayed page's point dimensions (width across the canvas, height down it). For a 90/270
|
||||||
|
// rotation the rendered bitmap is turned, so the point dims are swapped relative to the raw page.
|
||||||
|
private (double dispW, double dispH, double sx, double sy) CropDisplayDims(int pi, (double w, double h) dims)
|
||||||
|
{
|
||||||
|
_pageRotations.TryGetValue(pi, out int rot);
|
||||||
|
var page = _doc!.Pages[pi];
|
||||||
|
double pdfW = page.Width.Point, pdfH = page.Height.Point;
|
||||||
|
bool swap = rot == 90 || rot == 270;
|
||||||
|
double dispW = swap ? pdfH : pdfW;
|
||||||
|
double dispH = swap ? pdfW : pdfH;
|
||||||
|
return (dispW, dispH, dispW / dims.w, dispH / dims.h); // sx,sy: page-points per canvas-unit
|
||||||
|
}
|
||||||
|
|
||||||
|
// points -> the active display unit (relative to the page dimension for "%").
|
||||||
|
private double FromPoints(double pts, double pageDim) => _cropUnit switch
|
||||||
|
{
|
||||||
|
"in" => pts / 72.0,
|
||||||
|
"%" => pageDim > 0 ? pts / pageDim * 100.0 : 0,
|
||||||
|
_ => pts,
|
||||||
|
};
|
||||||
|
|
||||||
|
// active display unit -> points.
|
||||||
|
private double ToPoints(double val, double pageDim) => _cropUnit switch
|
||||||
|
{
|
||||||
|
"in" => val * 72.0,
|
||||||
|
"%" => val / 100.0 * pageDim,
|
||||||
|
_ => val,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Push <see cref="_cropCanvasRect"/> into the X/Y/W/H boxes as a top-left origin rectangle
|
||||||
|
/// (GIMP style) in the active unit. No-ops when the bar isn't showing.
|
||||||
|
/// </summary>
|
||||||
|
private void SyncCropBoxInputs()
|
||||||
|
{
|
||||||
|
if (_cropXBox is null || _doc is null) return;
|
||||||
|
int pi = _currentPage;
|
||||||
|
if (pi < 0 || !_renderDims.TryGetValue(pi, out var dims)) return;
|
||||||
|
var (dispW, dispH, sx, sy) = CropDisplayDims(pi, dims);
|
||||||
|
|
||||||
|
var r = _cropCanvasRect;
|
||||||
|
double xPt = Math.Max(0, r.X) * sx;
|
||||||
|
double yPt = Math.Max(0, r.Y) * sy;
|
||||||
|
double wPt = r.Width * sx;
|
||||||
|
double hPt = r.Height * sy;
|
||||||
|
xPt = Math.Min(xPt, dispW); yPt = Math.Min(yPt, dispH);
|
||||||
|
wPt = Math.Min(wPt, dispW - xPt); hPt = Math.Min(hPt, dispH - yPt);
|
||||||
|
|
||||||
|
string fmt = _cropUnit == "in" ? "F2" : "F1";
|
||||||
|
_updatingCropInputs = true;
|
||||||
|
_cropXBox.Text = FromPoints(xPt, dispW).ToString(fmt);
|
||||||
|
_cropYBox!.Text = FromPoints(yPt, dispH).ToString(fmt);
|
||||||
|
_cropWBox!.Text = FromPoints(wPt, dispW).ToString(fmt);
|
||||||
|
_cropHBox!.Text = FromPoints(hPt, dispH).ToString(fmt);
|
||||||
|
_updatingCropInputs = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read the X/Y/W/H boxes (top-left origin, active unit) -> update <see cref="_cropCanvasRect"/>.
|
||||||
|
/// Called on Enter or LostFocus inside a box.
|
||||||
|
/// </summary>
|
||||||
|
private void CommitCropBoxInput()
|
||||||
|
{
|
||||||
|
if (_updatingCropInputs || _cropXBox is null || _doc is null) return;
|
||||||
|
int pi = _currentPage;
|
||||||
|
if (pi < 0 || !_renderDims.TryGetValue(pi, out var dims)) return;
|
||||||
|
if (!double.TryParse(_cropXBox.Text, out double x)) return;
|
||||||
|
if (!double.TryParse(_cropYBox!.Text, out double y)) return;
|
||||||
|
if (!double.TryParse(_cropWBox!.Text, out double w)) return;
|
||||||
|
if (!double.TryParse(_cropHBox!.Text, out double h)) return;
|
||||||
|
|
||||||
|
var (dispW, dispH, sx, sy) = CropDisplayDims(pi, dims);
|
||||||
|
double xPt = ToPoints(x, dispW), yPt = ToPoints(y, dispH);
|
||||||
|
double wPt = ToPoints(w, dispW), hPt = ToPoints(h, dispH);
|
||||||
|
|
||||||
|
xPt = Math.Max(0, Math.Min(dispW - 1, xPt));
|
||||||
|
yPt = Math.Max(0, Math.Min(dispH - 1, yPt));
|
||||||
|
wPt = Math.Max(1, Math.Min(dispW - xPt, wPt));
|
||||||
|
hPt = Math.Max(1, Math.Min(dispH - yPt, hPt));
|
||||||
|
|
||||||
|
_cropCanvasRect = new Rect(xPt / sx, yPt / sy, Math.Max(10, wPt / sx), Math.Max(10, hPt / sy));
|
||||||
|
UpdateCropRectVisuals();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a page-range string like "1-3,5,7-9" (1-based) into a zero-based index array.
|
||||||
|
/// Returns <c>null</c> on parse error or if no valid pages are produced.
|
||||||
|
/// </summary>
|
||||||
|
private static int[]? ParsePageRange(string input, int pageCount)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input)) return null;
|
||||||
|
var result = new System.Collections.Generic.HashSet<int>();
|
||||||
|
foreach (var part in input.Split([','], StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var seg = part.Trim();
|
||||||
|
if (seg.Contains('-'))
|
||||||
|
{
|
||||||
|
var halves = seg.Split('-');
|
||||||
|
if (halves.Length == 2 &&
|
||||||
|
int.TryParse(halves[0].Trim(), out int lo) &&
|
||||||
|
int.TryParse(halves[1].Trim(), out int hi))
|
||||||
|
{
|
||||||
|
for (int p = lo; p <= hi; p++)
|
||||||
|
if (p >= 1 && p <= pageCount) result.Add(p - 1);
|
||||||
|
}
|
||||||
|
else return null;
|
||||||
|
}
|
||||||
|
else if (int.TryParse(seg, out int pg))
|
||||||
|
{
|
||||||
|
if (pg >= 1 && pg <= pageCount) result.Add(pg - 1);
|
||||||
|
}
|
||||||
|
else return null;
|
||||||
|
}
|
||||||
|
return result.Count == 0 ? null : [.. result.OrderBy(x => x)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entering the Crop tool drops a default crop box (inset from the page edges) and shows the bar
|
||||||
|
// straight away, so the box is visible without the user having to draw one first.
|
||||||
|
private void ShowDefaultCropBox()
|
||||||
|
{
|
||||||
|
if (_doc is null) return;
|
||||||
|
int pi = _currentPage;
|
||||||
|
if (pi < 0) return;
|
||||||
|
var canvas = VisibleCanvasForPage(pi) ?? CanvasForPage(pi);
|
||||||
|
if (canvas is null || canvas.Width <= 0 || canvas.Height <= 0) return;
|
||||||
|
|
||||||
|
_cropPageIndex = pi;
|
||||||
|
_activeCanvas = canvas;
|
||||||
|
_gestureCanvas = canvas;
|
||||||
|
_gesturePage = pi;
|
||||||
|
|
||||||
|
double w = canvas.Width, h = canvas.Height;
|
||||||
|
double mx = w * 0.08, my = h * 0.08;
|
||||||
|
_cropCanvasRect = new Rect(mx, my, Math.Max(10, w - 2 * mx), Math.Max(10, h - 2 * my));
|
||||||
|
|
||||||
|
_cropPreviewRect = new Rectangle
|
||||||
|
{
|
||||||
|
Stroke = Brushes.White, StrokeThickness = 1.5, StrokeDashArray = [5, 3],
|
||||||
|
Fill = AccentBrush(55), Width = _cropCanvasRect.Width, Height = _cropCanvasRect.Height,
|
||||||
|
IsHitTestVisible = false,
|
||||||
|
Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, ShadowDepth = 0, BlurRadius = 3, Opacity = 0.7 }
|
||||||
|
};
|
||||||
|
Canvas.SetLeft(_cropPreviewRect, _cropCanvasRect.X);
|
||||||
|
Canvas.SetTop(_cropPreviewRect, _cropCanvasRect.Y);
|
||||||
|
Panel.SetZIndex(_cropPreviewRect, 1);
|
||||||
|
canvas.Children.Add(_cropPreviewRect);
|
||||||
|
|
||||||
|
ShowCropConfirmBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuilds the crop bar (and its box) from the current rect so a language switch picks up the new
|
||||||
|
// locale - the bar is built once with Loc() snapshots and would otherwise stay in the old language.
|
||||||
|
// No-op if the bar isn't showing.
|
||||||
|
private void RebuildCropBarForLocale()
|
||||||
|
{
|
||||||
|
if (_cropConfirmBar is null) return;
|
||||||
|
var rect = _cropCanvasRect;
|
||||||
|
var canvas = _activeCanvas;
|
||||||
|
HideCropConfirmBar();
|
||||||
|
if (canvas is null || canvas.Width <= 0 || rect.Width <= 1 || rect.Height <= 1) return;
|
||||||
|
_cropCanvasRect = rect;
|
||||||
|
_cropPreviewRect = new Rectangle
|
||||||
|
{
|
||||||
|
Stroke = Brushes.White, StrokeThickness = 1.5, StrokeDashArray = [5, 3],
|
||||||
|
Fill = Brushes.Transparent, Width = rect.Width, Height = rect.Height,
|
||||||
|
IsHitTestVisible = false,
|
||||||
|
Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, ShadowDepth = 0, BlurRadius = 3, Opacity = 0.7 }
|
||||||
|
};
|
||||||
|
Canvas.SetLeft(_cropPreviewRect, rect.X);
|
||||||
|
Canvas.SetTop(_cropPreviewRect, rect.Y);
|
||||||
|
Panel.SetZIndex(_cropPreviewRect, 1);
|
||||||
|
canvas.Children.Add(_cropPreviewRect);
|
||||||
|
ShowCropConfirmBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowCropConfirmBar()
|
||||||
|
{
|
||||||
|
if (_doc is null) return;
|
||||||
|
if (_cropPreviewRect is not null)
|
||||||
|
{
|
||||||
|
// Committed box: outline only, but darker + a touch thicker so it reads on light scans.
|
||||||
|
_cropPreviewRect.Fill = Brushes.Transparent;
|
||||||
|
_cropPreviewRect.Stroke = new SolidColorBrush(Color.FromRgb(0x20, 0x20, 0x20));
|
||||||
|
_cropPreviewRect.StrokeThickness = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bar already up: do NOT rebuild it (that flickers it and wipes the Pages/All inputs). Just
|
||||||
|
// refresh the corner handles and the X/Y/W/H fields so the values track the box.
|
||||||
|
if (_cropConfirmBar is not null)
|
||||||
|
{
|
||||||
|
AddCropHandles();
|
||||||
|
SyncCropBoxInputs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int currentPage = _cropPageIndex >= 0 ? _cropPageIndex : _currentPage;
|
||||||
|
|
||||||
|
// Build the bar exactly like the other annotate bars: a drag grip first, then the controls,
|
||||||
|
// wrapped by the shared BuildBarHost and placed with PlaceAnnotationBar so it attaches to the top
|
||||||
|
// and slides left/right like Draw/Text/Highlight - no bespoke host, grain, drag, or positioning.
|
||||||
|
_annotBarDragInners.Clear();
|
||||||
|
var outer = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(8, 2, 8, 2), Background = Brushes.Transparent };
|
||||||
|
var grip = MakeBarGrip();
|
||||||
|
outer.Children.Add(grip);
|
||||||
|
static Button CropBtn(Button b) { b.Padding = new Thickness(10, 4, 10, 4); b.Margin = new Thickness(0, 0, 5, 0); return b; }
|
||||||
|
|
||||||
|
TextBlock LocalizedText(string key, FontWeight? weight = null)
|
||||||
|
{
|
||||||
|
var text = new TextBlock
|
||||||
|
{
|
||||||
|
FontFamily = UiKit.UiFont,
|
||||||
|
FontSize = 11,
|
||||||
|
FontWeight = weight ?? FontWeights.Normal,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
text.SetResourceReference(TextBlock.TextProperty, key);
|
||||||
|
text.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// label + themed field. Enter applies the crop; LostFocus just updates the rect from the values.
|
||||||
|
TextBox AddField(string lbl, double width)
|
||||||
|
{
|
||||||
|
var label = new TextBlock
|
||||||
|
{
|
||||||
|
Text = lbl, FontFamily = UiKit.UiFont, FontSize = 11,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 3, 0)
|
||||||
|
};
|
||||||
|
label.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
|
||||||
|
outer.Children.Add(label);
|
||||||
|
var tb = new TextBox
|
||||||
|
{
|
||||||
|
Width = width, Height = 22, FontFamily = UiKit.UiFont, FontSize = 11,
|
||||||
|
BorderThickness = new Thickness(1), Padding = new Thickness(3, 1, 3, 1),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center, VerticalContentAlignment = VerticalAlignment.Center,
|
||||||
|
Margin = new Thickness(0, 0, 8, 0), Style = (Style)FindResource("FormFieldTextBox")
|
||||||
|
};
|
||||||
|
tb.SetResourceReference(TextBox.BackgroundProperty, "PaneBrush");
|
||||||
|
tb.SetResourceReference(TextBox.ForegroundProperty, "TextBrush");
|
||||||
|
tb.SetResourceReference(TextBox.BorderBrushProperty, "CardBorderBrush");
|
||||||
|
tb.KeyDown += (_, e) => { if (e.Key == Key.Enter) { CommitCropBoxInput(); ApplyCrop([currentPage]); e.Handled = true; } };
|
||||||
|
tb.LostFocus += (_, _) => CommitCropBoxInput();
|
||||||
|
outer.Children.Add(tb);
|
||||||
|
return tb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group labels (GIMP-style "Position" / "Size") so the single-letter fields read clearly.
|
||||||
|
void GroupLabel(string key, double leftPad)
|
||||||
|
{
|
||||||
|
var label = LocalizedText(key, FontWeights.SemiBold);
|
||||||
|
label.Margin = new Thickness(leftPad, 0, 6, 0);
|
||||||
|
outer.Children.Add(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupLabel("Str_Crop_Position", 0);
|
||||||
|
_cropXBox = AddField("X", 50);
|
||||||
|
_cropYBox = AddField("Y", 50);
|
||||||
|
GroupLabel("Str_Crop_Size", 6); // padding after the Y box, before the size group
|
||||||
|
_cropWBox = AddField("W", 50);
|
||||||
|
_cropHBox = AddField("H", 50);
|
||||||
|
|
||||||
|
// Unit picker (pt / in / %); re-formats the fields on change.
|
||||||
|
var unitCombo = new ComboBox
|
||||||
|
{
|
||||||
|
Width = 54, Height = 22, Margin = new Thickness(0, 0, 8, 0),
|
||||||
|
VerticalContentAlignment = VerticalAlignment.Center,
|
||||||
|
Style = (Style)FindResource("DarkComboBox")
|
||||||
|
};
|
||||||
|
foreach (var u in new[] { "pt", "in", "%" }) unitCombo.Items.Add(u);
|
||||||
|
unitCombo.SelectedItem = _cropUnit;
|
||||||
|
unitCombo.SelectionChanged += (_, _) => { _cropUnit = unitCombo.SelectedItem as string ?? "pt"; SyncCropBoxInputs(); };
|
||||||
|
outer.Children.Add(unitCombo);
|
||||||
|
|
||||||
|
// Divider before the action buttons.
|
||||||
|
var divider = new Border { Width = 1, Margin = new Thickness(2, 0, 8, 0), VerticalAlignment = VerticalAlignment.Stretch };
|
||||||
|
divider.SetResourceReference(Border.BackgroundProperty, "CardBorderBrush");
|
||||||
|
outer.Children.Add(divider);
|
||||||
|
|
||||||
|
// Pages range + "All" checkbox, then a single Crop button on the far right. Crop logic:
|
||||||
|
// All checked -> every page; else a typed range like "1-3,5"; else just the current page.
|
||||||
|
var pagesLabel = LocalizedText("Str_Crop_Pages");
|
||||||
|
pagesLabel.Margin = new Thickness(0, 0, 3, 0);
|
||||||
|
outer.Children.Add(pagesLabel);
|
||||||
|
_cropRangeBox = new TextBox
|
||||||
|
{
|
||||||
|
Width = 64, Height = 22, FontFamily = UiKit.UiFont, FontSize = 11,
|
||||||
|
BorderThickness = new Thickness(1), Padding = new Thickness(3, 1, 3, 1),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center, VerticalContentAlignment = VerticalAlignment.Center,
|
||||||
|
Margin = new Thickness(0, 0, 8, 0),
|
||||||
|
Style = (Style)FindResource("FormFieldTextBox")
|
||||||
|
};
|
||||||
|
_cropRangeBox.SetResourceReference(FrameworkElement.ToolTipProperty, "Str_Crop_RangeTip");
|
||||||
|
_cropRangeBox.SetResourceReference(TextBox.BackgroundProperty, "PaneBrush");
|
||||||
|
_cropRangeBox.SetResourceReference(TextBox.ForegroundProperty, "TextBrush");
|
||||||
|
_cropRangeBox.SetResourceReference(TextBox.BorderBrushProperty, "CardBorderBrush");
|
||||||
|
outer.Children.Add(_cropRangeBox);
|
||||||
|
|
||||||
|
// "All" checkbox - the same compact look as the annotate-bar toggles (no WPF CheckBox chrome).
|
||||||
|
bool cropAll = false;
|
||||||
|
var allTick = new TextBlock { Text = "✓", Foreground = Brushes.White, FontSize = 10, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, Visibility = Visibility.Collapsed };
|
||||||
|
var allBox = new Border { Width = 15, Height = 15, CornerRadius = new CornerRadius(3), BorderThickness = new Thickness(1), BorderBrush = _swatchDimBorder, Background = Brushes.Transparent, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0), Child = allTick };
|
||||||
|
var allRow = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center, Cursor = Cursors.Hand, Margin = new Thickness(0, 0, 10, 0) };
|
||||||
|
allRow.SetResourceReference(FrameworkElement.ToolTipProperty, "Str_Crop_AllTip");
|
||||||
|
allRow.Children.Add(allBox);
|
||||||
|
allRow.Children.Add(LocalizedText("Str_Crop_All"));
|
||||||
|
allRow.MouseLeftButtonDown += (_, _) =>
|
||||||
|
{
|
||||||
|
cropAll = !cropAll;
|
||||||
|
allTick.Visibility = cropAll ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
if (cropAll) allBox.SetResourceReference(Border.BackgroundProperty, "SelectionAccent");
|
||||||
|
else { allBox.Background = Brushes.Transparent; allBox.BorderBrush = _swatchDimBorder; }
|
||||||
|
};
|
||||||
|
outer.Children.Add(allRow);
|
||||||
|
|
||||||
|
// Single Crop button on the right.
|
||||||
|
var cropBtn = CropBtn(UiKit.Make(Loc("Str_Crop_Apply"), true));
|
||||||
|
cropBtn.SetResourceReference(ContentControl.ContentProperty, "Str_Crop_Apply");
|
||||||
|
cropBtn.SetResourceReference(FrameworkElement.ToolTipProperty, "Str_TT_CropThisPage");
|
||||||
|
cropBtn.Click += (_, _) =>
|
||||||
|
{
|
||||||
|
int pc = _doc?.PageCount ?? 0;
|
||||||
|
int[]? pages;
|
||||||
|
if (cropAll) pages = [.. Enumerable.Range(0, pc)];
|
||||||
|
else if (!string.IsNullOrWhiteSpace(_cropRangeBox?.Text))
|
||||||
|
{
|
||||||
|
pages = ParsePageRange(_cropRangeBox!.Text, pc);
|
||||||
|
if (pages is null) { SetStatus(Loc("Str_InvalidRange")); return; }
|
||||||
|
}
|
||||||
|
else pages = [currentPage];
|
||||||
|
ApplyCrop(pages);
|
||||||
|
};
|
||||||
|
outer.Children.Add(cropBtn);
|
||||||
|
|
||||||
|
// Wrap the controls in the shared bar host + frame and place it like the other annotate bars
|
||||||
|
// (top, right-anchored, slidable via the grip; the X position persists across tools).
|
||||||
|
var bar = new Border
|
||||||
|
{
|
||||||
|
BorderThickness = new Thickness(1, 0, 1, 1),
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
VerticalAlignment = VerticalAlignment.Top,
|
||||||
|
CornerRadius = new CornerRadius(0, 0, 4, 4),
|
||||||
|
Padding = new Thickness(4),
|
||||||
|
Effect = AnnotBarShadow(),
|
||||||
|
Child = BuildBarHost(outer)
|
||||||
|
};
|
||||||
|
bar.SetResourceReference(Border.BackgroundProperty, "BgFlyout");
|
||||||
|
bar.SetResourceReference(Border.BorderBrushProperty, "PaneBorderBrush");
|
||||||
|
_cropConfirmBar = bar;
|
||||||
|
|
||||||
|
var previewArea = PagePreviewPanel.Parent as Grid;
|
||||||
|
if (previewArea is not null)
|
||||||
|
{
|
||||||
|
Panel.SetZIndex(bar, 100);
|
||||||
|
previewArea.Children.Add(bar);
|
||||||
|
PlaceAnnotationBar(bar, grip, fadeIn: false);
|
||||||
|
}
|
||||||
|
_annotBarTool = EditTool.Crop; // so re-clicking the Crop tool minimizes this bar like the others
|
||||||
|
_annotBarMinimized = false;
|
||||||
|
AddCropHandles();
|
||||||
|
SyncCropBoxInputs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HideCropConfirmBar()
|
||||||
|
{
|
||||||
|
if (_cropConfirmBar is not null)
|
||||||
|
{
|
||||||
|
// Remove from whichever panel it was added to (outer grid or canvas fallback)
|
||||||
|
(_annotationCanvas.Parent as Panel)?.Children.Remove(_cropConfirmBar);
|
||||||
|
_annotationCanvas.Children.Remove(_cropConfirmBar); // no-op if not there
|
||||||
|
(PagePreviewPanel.Parent as Panel)?.Children.Remove(_cropConfirmBar);
|
||||||
|
_cropConfirmBar = null;
|
||||||
|
}
|
||||||
|
if (_cropPreviewRectBorder is not null)
|
||||||
|
{
|
||||||
|
(_cropPreviewRectBorder.Parent as Panel)?.Children.Remove(_cropPreviewRectBorder);
|
||||||
|
_annotationCanvas.Children.Remove(_cropPreviewRectBorder);
|
||||||
|
_cropPreviewRectBorder = null;
|
||||||
|
}
|
||||||
|
if (_cropPreviewRect is not null)
|
||||||
|
{
|
||||||
|
(_cropPreviewRect.Parent as Panel)?.Children.Remove(_cropPreviewRect);
|
||||||
|
_annotationCanvas.Children.Remove(_cropPreviewRect);
|
||||||
|
_cropPreviewRect = null;
|
||||||
|
}
|
||||||
|
RemoveCropHandles();
|
||||||
|
_cropXBox = _cropYBox = _cropWBox = _cropHBox = null;
|
||||||
|
_cropRangeBox = null;
|
||||||
|
if (_annotBarTool == EditTool.Crop) _annotBarTool = null; // release the shared annotate-bar slot
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddCropHandles()
|
||||||
|
{
|
||||||
|
RemoveCropHandles();
|
||||||
|
const double hSize = 24;
|
||||||
|
var tags = new[] { "NW", "NE", "SE", "SW" };
|
||||||
|
var cursors = new[] { Cursors.SizeNWSE, Cursors.SizeNESW, Cursors.SizeNWSE, Cursors.SizeNESW };
|
||||||
|
// Handles live in the OUTER unscaled panel (same as the confirm bar) so they render
|
||||||
|
// at a fixed screen size regardless of canvas zoom level.
|
||||||
|
var outerGrid = PagePreviewPanel.Parent as Panel ?? (Panel)_annotationCanvas;
|
||||||
|
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
{
|
||||||
|
var tag = tags[i];
|
||||||
|
var h = new Rectangle
|
||||||
|
{
|
||||||
|
Width = hSize, Height = hSize,
|
||||||
|
Fill = Brushes.Transparent,
|
||||||
|
Stroke = new SolidColorBrush(Color.FromRgb(0x88, 0x88, 0x88)),
|
||||||
|
StrokeThickness = 1.5,
|
||||||
|
Effect = new System.Windows.Media.Effects.DropShadowEffect
|
||||||
|
{ Color = Colors.Black, ShadowDepth = 0, BlurRadius = 3, Opacity = 0.6 },
|
||||||
|
Tag = tag,
|
||||||
|
Cursor = cursors[i],
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Left,
|
||||||
|
VerticalAlignment = VerticalAlignment.Top,
|
||||||
|
};
|
||||||
|
Panel.SetZIndex(h, 101);
|
||||||
|
// Attach drag directly on the handle so clicks don't need to reach _annotationCanvas.
|
||||||
|
h.MouseLeftButtonDown += (_, e) =>
|
||||||
|
{
|
||||||
|
_activeCropHandleTag = tag;
|
||||||
|
// Measure and capture against the active surface (per-page overlay in
|
||||||
|
// Continuous view) so the drag delta matches the crop rect's coordinate space.
|
||||||
|
_cropHandleDragStart = e.GetPosition(_activeCanvas);
|
||||||
|
_cropRectAtHandleDrag = _cropCanvasRect;
|
||||||
|
_activeCanvas.CaptureMouse();
|
||||||
|
e.Handled = true;
|
||||||
|
};
|
||||||
|
_cropHandles.Add(h);
|
||||||
|
outerGrid.Children.Add(h);
|
||||||
|
}
|
||||||
|
PositionCropHandles();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveCropHandles()
|
||||||
|
{
|
||||||
|
var outerGrid = PagePreviewPanel.Parent as Panel ?? (Panel)_annotationCanvas;
|
||||||
|
foreach (var h in _cropHandles)
|
||||||
|
{
|
||||||
|
outerGrid.Children.Remove(h);
|
||||||
|
_annotationCanvas.Children.Remove(h); // belt-and-suspenders in case it ended up in canvas
|
||||||
|
}
|
||||||
|
_cropHandles.Clear();
|
||||||
|
_activeCropHandleTag = null;
|
||||||
|
RemoveCropBrackets(); // no-op - list is always empty now, kept for safety
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveCropBrackets()
|
||||||
|
{
|
||||||
|
foreach (var b in _cropBrackets) _annotationCanvas.Children.Remove(b);
|
||||||
|
_cropBrackets.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PositionCropHandles()
|
||||||
|
{
|
||||||
|
if (_cropHandles.Count < 4) return;
|
||||||
|
const double hSize = 24;
|
||||||
|
var outerGrid = PagePreviewPanel.Parent as UIElement ?? _annotationCanvas;
|
||||||
|
// Translate canvas-space corners to outer-panel screen space (same as RepositionCropConfirmBar).
|
||||||
|
var canvasCorners = new Point[]
|
||||||
|
{
|
||||||
|
new(_cropCanvasRect.X, _cropCanvasRect.Y),
|
||||||
|
new(_cropCanvasRect.Right, _cropCanvasRect.Y),
|
||||||
|
new(_cropCanvasRect.Right, _cropCanvasRect.Bottom),
|
||||||
|
new(_cropCanvasRect.X, _cropCanvasRect.Bottom),
|
||||||
|
};
|
||||||
|
var offsets = new (double dx, double dy)[]
|
||||||
|
{
|
||||||
|
(0, 0 ), // NW: top-left at top-left corner
|
||||||
|
(-hSize, 0 ), // NE: top-right at top-right corner
|
||||||
|
(-hSize, -hSize ), // SE: bottom-right at bottom-right corner
|
||||||
|
(0, -hSize ), // SW: bottom-left at bottom-left corner
|
||||||
|
};
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
{
|
||||||
|
Point screen = _activeCanvas.TranslatePoint(canvasCorners[i], outerGrid);
|
||||||
|
_cropHandles[i].Margin = new Thickness(
|
||||||
|
screen.X + offsets[i].dx,
|
||||||
|
screen.Y + offsets[i].dy,
|
||||||
|
0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateCropRectVisuals()
|
||||||
|
{
|
||||||
|
if (_cropPreviewRect is null) return;
|
||||||
|
var r = _cropCanvasRect;
|
||||||
|
Canvas.SetLeft(_cropPreviewRect, r.X); Canvas.SetTop(_cropPreviewRect, r.Y);
|
||||||
|
_cropPreviewRect.Width = r.Width; _cropPreviewRect.Height = r.Height;
|
||||||
|
PositionCropHandles();
|
||||||
|
SyncCropBoxInputs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyCrop(int[] pageIndices)
|
||||||
|
{
|
||||||
|
if (_doc is null || _currentFile is null) { SetStatus(Loc("Str_CropNoDoc")); return; }
|
||||||
|
int currentPage = _cropPageIndex >= 0 ? _cropPageIndex : _currentPage;
|
||||||
|
if (currentPage < 0) { SetStatus(Loc("Str_CropNoPage")); return; }
|
||||||
|
if (!_renderDims.TryGetValue(currentPage, out var refDims))
|
||||||
|
{ SetStatus(Loc("Str_CropNoDims")); return; }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PushDocUndo();
|
||||||
|
|
||||||
|
// Convert canvas rect to PDF CropBox coords using the rotation-aware helper.
|
||||||
|
// This is the correct inversion of how Docnet renders the rotated bitmap.
|
||||||
|
_pageRotations.TryGetValue(currentPage, out int rot);
|
||||||
|
var refPage = _doc.Pages[currentPage];
|
||||||
|
double refPdfW = refPage.Width.Point;
|
||||||
|
double refPdfH = refPage.Height.Point;
|
||||||
|
|
||||||
|
var (rx1, ry1, rx2, ry2) = CanvasToPdfRect(
|
||||||
|
_cropCanvasRect, refPdfW, refPdfH, refDims.w, refDims.h, rot);
|
||||||
|
|
||||||
|
foreach (int pi in pageIndices)
|
||||||
|
{
|
||||||
|
if (pi < 0 || pi >= _doc.PageCount) continue;
|
||||||
|
var page = _doc.Pages[pi];
|
||||||
|
double pW = page.Width.Point;
|
||||||
|
double pH = page.Height.Point;
|
||||||
|
|
||||||
|
// Scale proportionally when "All Pages" spans pages of different sizes
|
||||||
|
double x1 = rx1 * pW / refPdfW;
|
||||||
|
double y1 = ry1 * pH / refPdfH;
|
||||||
|
double x2 = rx2 * pW / refPdfW;
|
||||||
|
double y2 = ry2 * pH / refPdfH;
|
||||||
|
|
||||||
|
// Clamp to media box and ensure minimum 1-pt size
|
||||||
|
x1 = Math.Max(0, x1); y1 = Math.Max(0, y1);
|
||||||
|
x2 = Math.Min(pW, x2); y2 = Math.Min(pH, y2);
|
||||||
|
if (x2 - x1 < 1) x2 = x1 + 1;
|
||||||
|
if (y2 - y1 < 1) y2 = y1 + 1;
|
||||||
|
|
||||||
|
// Write CropBox directly into the page dictionary (more reliable across
|
||||||
|
// PdfSharpCore versions than the CropBox property setter).
|
||||||
|
var cropArr = new PdfSharpCore.Pdf.PdfArray();
|
||||||
|
cropArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(x1));
|
||||||
|
cropArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(y1));
|
||||||
|
cropArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(x2));
|
||||||
|
cropArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(y2));
|
||||||
|
page.Elements["/CropBox"] = cropArr;
|
||||||
|
|
||||||
|
// Mirror to TrimBox (PDF spec: TrimBox within CropBox within MediaBox)
|
||||||
|
var trimArr = new PdfSharpCore.Pdf.PdfArray();
|
||||||
|
trimArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(x1));
|
||||||
|
trimArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(y1));
|
||||||
|
trimArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(x2));
|
||||||
|
trimArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(y2));
|
||||||
|
page.Elements["/TrimBox"] = trimArr;
|
||||||
|
}
|
||||||
|
|
||||||
|
HideCropConfirmBar();
|
||||||
|
SetTool(EditTool.Select);
|
||||||
|
SaveTempAndReload(keepAnnotations: true, preserveZoom: true);
|
||||||
|
SetStatus(string.Format(Loc("Str_Cropped"), pageIndices.Length));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
SetStatus(string.Format(Loc("Str_CropFailed"), ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveCropBox(int[] pageIndices)
|
||||||
|
{
|
||||||
|
if (_doc is null || _currentFile is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PushDocUndo();
|
||||||
|
foreach (int pi in pageIndices)
|
||||||
|
{
|
||||||
|
if (pi < 0 || pi >= _doc.PageCount) continue;
|
||||||
|
_doc.Pages[pi].Elements.Remove("/CropBox");
|
||||||
|
_doc.Pages[pi].Elements.Remove("/TrimBox");
|
||||||
|
}
|
||||||
|
HideCropConfirmBar();
|
||||||
|
SetTool(EditTool.Select);
|
||||||
|
SaveTempAndReload(keepAnnotations: true);
|
||||||
|
SetStatus(string.Format(Loc("Str_RemovedCrop"), pageIndices.Length));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
SetStatus(string.Format(Loc("Str_RemoveCropFailed"), ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,675 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Animation;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using System.Windows.Shapes;
|
||||||
|
using Docnet.Core;
|
||||||
|
using Docnet.Core.Models;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using PdfSharpCore.Drawing;
|
||||||
|
using PdfSharpCore.Pdf;
|
||||||
|
using PdfSharpCore.Pdf.IO;
|
||||||
|
using KillerPDF.Services;
|
||||||
|
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
|
||||||
|
|
||||||
|
namespace KillerPDF.Controls
|
||||||
|
{
|
||||||
|
// Moved from Shell/Links.cs; the namespace and class line are the only changes. Window members
|
||||||
|
// spelled bare here resolve through PdfViewer.Bridge.cs.
|
||||||
|
public partial class PdfViewer
|
||||||
|
{
|
||||||
|
// ============================================================
|
||||||
|
// PDF Link Annotation Overlays
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// LinkInfo lives in Models/LinkTypes.cs, not here - ContextMenu.cs is on the window and also
|
||||||
|
// reads the link rects, so the type cannot be nested in whichever class owns them.
|
||||||
|
|
||||||
|
// Per-page link rects for the tiled views (continuous / grid / two-page), keyed by page index.
|
||||||
|
// Clicks and the hover cursor are resolved by bounds-testing these in Canvas_MouseLeftButtonDown
|
||||||
|
// and Canvas_MouseMove: a per-link overlay swallows the click in the tiled layout but its own
|
||||||
|
// handler never fires, so no visual overlay is created - these rects are the source of truth.
|
||||||
|
private readonly Dictionary<int, List<LinkInfo>> _continuousLinks = [];
|
||||||
|
|
||||||
|
/// <summary>The link-rect map, for the window side. ContextMenu.cs bounds-tests it to build
|
||||||
|
/// the right-click menu and FileOperations.cs clears it on document change; both live on the
|
||||||
|
/// window while Links.cs lives here.</summary>
|
||||||
|
internal Dictionary<int, List<LinkInfo>> ContinuousLinks => _continuousLinks;
|
||||||
|
|
||||||
|
/// <summary>Hit-slop around a link rect, shared with ContextMenu.cs so the menu targets the
|
||||||
|
/// same links the click and hover paths do.</summary>
|
||||||
|
internal const double LinkHitPadShared = LinkHitPad;
|
||||||
|
|
||||||
|
// Small hit-slop (render-dim units) added around a link rect for click / hover / right-click
|
||||||
|
// hit-testing so thin one-line link strips are easy to hit without over-reaching neighbors.
|
||||||
|
// Applied identically in single-page (grows the overlay in RenderPageLinks) and tiled views
|
||||||
|
// (bounds-checks) so both feel the same.
|
||||||
|
private const double LinkHitPad = 5;
|
||||||
|
|
||||||
|
// Persisted opt-IN for the click-safety confirmation prompt, surfaced as the
|
||||||
|
// "Confirm before opening links" toggle on the About card footer.
|
||||||
|
//
|
||||||
|
// Positive sense and default OFF: links keep opening immediately unless you ask for the
|
||||||
|
// prompt. ONE key, deliberately - a hardcoded master switch plus an inverted
|
||||||
|
// "SkipLinkConfirm" opt-out can disagree with each other. The dialog's "Don't ask again" is
|
||||||
|
// the same switch as the checkbox.
|
||||||
|
internal const string ConfirmLinksSetting = "ConfirmLinks";
|
||||||
|
|
||||||
|
// Confirms before opening an external link in the browser, unless the user opted out. Returns true
|
||||||
|
// to proceed. Internal go-to-page links never call this.
|
||||||
|
private bool ConfirmOpenLink(string url)
|
||||||
|
{
|
||||||
|
if (App.GetSetting(ConfirmLinksSetting) != "1") return true;
|
||||||
|
var (result, dontAsk) = KillerDialog.ShowWithCheckbox(
|
||||||
|
Host!.Window,
|
||||||
|
$"{Loc("Str_LinkConfirmBody")}\n\n{url}",
|
||||||
|
Loc("Str_LinkDontAsk"),
|
||||||
|
Loc("Str_LinkConfirmTitle"),
|
||||||
|
MessageBoxButton.OKCancel);
|
||||||
|
if (result != MessageBoxResult.OK) return false;
|
||||||
|
// "Don't ask again" IS the toggle, so turn it off rather than setting a second key the
|
||||||
|
// About checkbox knows nothing about - that is how the two could drift apart before.
|
||||||
|
if (dontAsk)
|
||||||
|
{
|
||||||
|
App.SetSetting(ConfirmLinksSetting, "0");
|
||||||
|
if (LinkConfirmCheck != null) LinkConfirmCheck.IsChecked = false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schemes we will hand to the OS shell when a PDF link is clicked. A PDF can embed ANY URI, and
|
||||||
|
// Process.Start(UseShellExecute=true) would happily launch file:// paths, UNC shares, javascript:,
|
||||||
|
// or registered protocol handlers (ms-msdt:/search-ms: - real malware vectors). Anything outside
|
||||||
|
// this allow-list is refused. http/https = web links; mailto = email links.
|
||||||
|
private static readonly HashSet<string> AllowedLinkSchemes =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase) { "http", "https", "mailto" };
|
||||||
|
|
||||||
|
// True only for an absolute URI in an allowed scheme. Rejects scheme-less / relative URIs (a bare
|
||||||
|
// "www.example.com" is a Tier 2 follow-up), plus file:, javascript:, and custom protocol handlers.
|
||||||
|
private static bool IsAllowedLinkUri(string url) =>
|
||||||
|
Uri.TryCreate(url, UriKind.Absolute, out var uri) && AllowedLinkSchemes.Contains(uri.Scheme);
|
||||||
|
|
||||||
|
// A PDF can store a scheme-less link like "www.example.com" or "example.com/page". Treat a domain-
|
||||||
|
// shaped target as https so it still opens; anything with an explicit scheme, a backslash (UNC/path),
|
||||||
|
// or whitespace is left untouched (and thus refused by IsAllowedLinkUri unless it's http/https/mailto).
|
||||||
|
private static string NormalizeLinkUri(string raw)
|
||||||
|
{
|
||||||
|
raw = raw.Trim();
|
||||||
|
if (raw.Length == 0) return raw;
|
||||||
|
if (raw.Contains('\\') || raw.Contains(' ')) return raw; // Windows path / UNC / junk - don't touch
|
||||||
|
if (raw.Contains("://")) return raw; // already scheme://...
|
||||||
|
int colon = raw.IndexOf(':');
|
||||||
|
int slash = raw.IndexOf('/');
|
||||||
|
if (colon >= 0 && (slash < 0 || colon < slash)) return raw; // "scheme:" (mailto:, file:, C:) - don't touch
|
||||||
|
string host = slash >= 0 ? raw[..slash] : raw; // host part before any path
|
||||||
|
return host.Contains('.') ? "https://" + raw : raw; // dotted host => assume https
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps a PDF rectangle (points, origin bottom-left, already min/max-normalized) to a canvas-space
|
||||||
|
// rectangle (pixels, origin top-left) for a page rendered at bitmapW x bitmapH. Shared by the
|
||||||
|
// PdfSharpCore and PDFium link readers so the two stay pixel-identical.
|
||||||
|
private static (double x, double y, double w, double h) PdfRectToCanvas(
|
||||||
|
double rx1, double ry1, double rx2, double ry2,
|
||||||
|
double pageWidthPt, double pageHeightPt, int bitmapW, int bitmapH)
|
||||||
|
{
|
||||||
|
double x = rx1 / pageWidthPt * bitmapW;
|
||||||
|
double y = (pageHeightPt - ry2) / pageHeightPt * bitmapH;
|
||||||
|
double w = (rx2 - rx1) / pageWidthPt * bitmapW;
|
||||||
|
double h = (ry2 - ry1) / pageHeightPt * bitmapH;
|
||||||
|
return (x, y, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Follows a resolved link target: an int page index navigates within the document; a string URI
|
||||||
|
/// is scheme-checked, confirmed, then opened via the shell. Single choke point for both the
|
||||||
|
/// single-page (_linkOverlays) and tiled (_continuousLinks) click paths, so the safety checks
|
||||||
|
/// can't be bypassed by one route and a failed open is always reported instead of silent.
|
||||||
|
/// </summary>
|
||||||
|
private void FollowLinkTarget(object? target)
|
||||||
|
{
|
||||||
|
if (target is int pageIndex)
|
||||||
|
{
|
||||||
|
if (_doc != null && pageIndex >= 0 && pageIndex < _doc.PageCount)
|
||||||
|
{
|
||||||
|
RecordNavJump(); // Alt+Left retraces the link hop
|
||||||
|
_currentPage = pageIndex;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target is not string raw || string.IsNullOrWhiteSpace(raw)) return;
|
||||||
|
|
||||||
|
// Scheme-less but domain-shaped targets (e.g. "www.example.com") become https:// here.
|
||||||
|
string url = NormalizeLinkUri(raw);
|
||||||
|
if (!IsAllowedLinkUri(url))
|
||||||
|
{
|
||||||
|
SetStatus($"{Loc("Str_LinkBlocked")} {raw}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ConfirmOpenLink(url)) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
System.Diagnostics.Debug.WriteLine($"Open link failed: {ex}");
|
||||||
|
SetStatus(Loc("Str_LinkOpenFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds the right-click actions for a link onto `menu`: Open Link (via the safe FollowLinkTarget
|
||||||
|
// path), Copy Link Address / Copy Email Address, and - only for PdfSharpCore-sourced links
|
||||||
|
// (annotIndex >= 0) - Remove Link from PDF. Shared by the single-page overlay menu and the tiled-
|
||||||
|
// view canvas menu so both views offer the same actions.
|
||||||
|
private void AddLinkMenuItems(ContextMenu menu, object target, int annotIndex, int pageIndex)
|
||||||
|
{
|
||||||
|
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_OpenLink"), (_, _) => FollowLinkTarget(target), glyph: ""));
|
||||||
|
if (target is string uri)
|
||||||
|
{
|
||||||
|
if (uri.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_CopyEmail"), (_, _) => TrySetClipboard(uri["mailto:".Length..]), "Ctrl+C", ""));
|
||||||
|
else
|
||||||
|
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_CopyLink"), (_, _) => TrySetClipboard(uri), "Ctrl+C", ""));
|
||||||
|
}
|
||||||
|
if (annotIndex >= 0)
|
||||||
|
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_RemoveLink"), (_, _) => RemoveLinkAnnotation(pageIndex, annotIndex), "Delete", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clipboard COM calls throw when another app is holding the clipboard open; swallow so a copy
|
||||||
|
// never crashes the app (the worst case is the copy silently not happening).
|
||||||
|
private static void TrySetClipboard(string text)
|
||||||
|
{
|
||||||
|
try { Clipboard.SetText(text); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status-bar hover feedback: shows the hovered link's target, restoring the prior status on exit.
|
||||||
|
private string? _preHoverStatus;
|
||||||
|
private void ShowLinkHoverStatus(string? target)
|
||||||
|
{
|
||||||
|
if (target != null)
|
||||||
|
{
|
||||||
|
_preHoverStatus ??= StatusText.Text;
|
||||||
|
StatusText.Text = target;
|
||||||
|
}
|
||||||
|
else if (_preHoverStatus != null)
|
||||||
|
{
|
||||||
|
StatusText.Text = _preHoverStatus;
|
||||||
|
_preHoverStatus = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Carries the link target (page index or URI string) plus the annotation's location in
|
||||||
|
/// the PDF so the overlay can be used to remove the native annotation on demand.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class LinkAnnotInfo(object target, int pageIndex, int annotIndex)
|
||||||
|
{
|
||||||
|
public object Target { get; } = target; // int pageIndex or string URI
|
||||||
|
public int PageIndex { get; } = pageIndex; // 0-based page in _doc
|
||||||
|
public int AnnotIndex { get; } = annotIndex; // index inside page /Annots array
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses all link annotations from a PDF page and converts them to canvas-space
|
||||||
|
/// rectangles. Works for both primary and secondary page renders.
|
||||||
|
/// </summary>
|
||||||
|
private List<LinkInfo> GetPageLinks(int pageIndex, int bitmapW, int bitmapH)
|
||||||
|
{
|
||||||
|
var links = new List<LinkInfo>();
|
||||||
|
if (_doc is null) return links;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pdfPage = _doc.Pages[pageIndex];
|
||||||
|
var annotsArr = pdfPage.Elements.GetArray("/Annots");
|
||||||
|
if (annotsArr is null || annotsArr.Elements.Count == 0) return links;
|
||||||
|
|
||||||
|
double pageWidthPt = pdfPage.Width.Point;
|
||||||
|
double pageHeightPt = pdfPage.Height.Point;
|
||||||
|
if (pageWidthPt <= 0) pageWidthPt = 595.28;
|
||||||
|
if (pageHeightPt <= 0) pageHeightPt = 841.89;
|
||||||
|
|
||||||
|
for (int i = 0; i < annotsArr.Elements.Count; i++)
|
||||||
|
{
|
||||||
|
PdfItem? elem = annotsArr.Elements[i];
|
||||||
|
PdfDictionary? ann = elem as PdfDictionary ?? DerefItem(elem) as PdfDictionary;
|
||||||
|
if (ann is null) continue;
|
||||||
|
|
||||||
|
var subtype = ann.Elements["/Subtype"]?.ToString() ?? "";
|
||||||
|
if (!subtype.Contains("Link")) continue;
|
||||||
|
|
||||||
|
var rectArr = ann.Elements.GetArray("/Rect");
|
||||||
|
if (rectArr is null || rectArr.Elements.Count < 4) continue;
|
||||||
|
double rx1 = rectArr.Elements.GetReal(0);
|
||||||
|
double ry1 = rectArr.Elements.GetReal(1);
|
||||||
|
double rx2 = rectArr.Elements.GetReal(2);
|
||||||
|
double ry2 = rectArr.Elements.GetReal(3);
|
||||||
|
if (rx1 > rx2) (rx1, rx2) = (rx2, rx1);
|
||||||
|
if (ry1 > ry2) (ry1, ry2) = (ry2, ry1);
|
||||||
|
|
||||||
|
var (cx, cy, cw, ch) = PdfRectToCanvas(rx1, ry1, rx2, ry2, pageWidthPt, pageHeightPt, bitmapW, bitmapH);
|
||||||
|
if (cw < 1 || ch < 1) continue;
|
||||||
|
|
||||||
|
int? targetPage = null;
|
||||||
|
string? uri = null;
|
||||||
|
|
||||||
|
var actionDict = ann.Elements.GetDictionary("/A");
|
||||||
|
if (actionDict != null)
|
||||||
|
{
|
||||||
|
var s = actionDict.Elements["/S"]?.ToString() ?? "";
|
||||||
|
if (s.Contains("GoTo"))
|
||||||
|
targetPage = ResolveDest(actionDict.Elements["/D"]);
|
||||||
|
else if (s.Contains("URI"))
|
||||||
|
uri = actionDict.Elements.GetString("/URI");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
targetPage = ResolveDest(ann.Elements["/Dest"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetPage is null && uri is null) continue;
|
||||||
|
|
||||||
|
object tag = targetPage.HasValue ? (object)targetPage.Value : uri!;
|
||||||
|
string tip = targetPage.HasValue ? $"Go to page {targetPage.Value + 1}" : uri!;
|
||||||
|
links.Add(new LinkInfo(cx, cy, cw, ch, tag, tip, i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"GetPageLinks (PdfSharpCore): {ex}"); }
|
||||||
|
|
||||||
|
// PdfSharpCore cannot dereference link annotations stored in object streams (common in
|
||||||
|
// linearized / PDF 1.5+ files): it sees the /Annots references but resolves them to null,
|
||||||
|
// yielding zero links. PDFium reads object streams natively, so when PdfSharpCore found no
|
||||||
|
// links here, fall back to it. The early "no /Annots" return above means this only runs on
|
||||||
|
// pages that actually declare annotations, so link-free pages never pay the PDFium cost.
|
||||||
|
if (links.Count == 0)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var viaPdfium = GetPageLinksViaPdfium(pageIndex, bitmapW, bitmapH);
|
||||||
|
if (viaPdfium.Count > 0) return viaPdfium;
|
||||||
|
}
|
||||||
|
catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"GetPageLinks (PDFium fallback): {ex}"); }
|
||||||
|
}
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// PDFium link extraction (fallback for object-stream PDFs)
|
||||||
|
//
|
||||||
|
// PdfSharpCore silently drops link annotations stored in object streams (linearized /
|
||||||
|
// PDF 1.5+). PDFium - already shipped with Docnet and used elsewhere for security
|
||||||
|
// stripping - resolves them natively via Services/PdfiumInterop.cs.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
private const int PDFACTION_GOTO = 1;
|
||||||
|
private const int PDFACTION_URI = 3;
|
||||||
|
|
||||||
|
// ALL direct PDFium P/Invoke (the link + page-size entry points included) lives in
|
||||||
|
// Services/PdfiumInterop.cs - one class, one lock (Docnet's), auditable discipline.
|
||||||
|
|
||||||
|
// Cached PDFium document handle for link extraction. Object-stream PDFs take the PDFium fallback
|
||||||
|
// on every annotated page; without this we'd FPDF_LoadDocument (re-parse the whole file) once per
|
||||||
|
// page during a render sweep. Keyed by path so it self-heals when the working file changes
|
||||||
|
// (SaveTempAndReload swaps in a new temp). NOTE: on a plain open _currentFile IS the user's real
|
||||||
|
// file (it is only a temp copy after a page edit or repair), so holding this open blocks saving
|
||||||
|
// over that file - every save-over path calls CloseLinkPdfiumDoc() first (#129). Only touched from
|
||||||
|
// UI-thread render paths (RenderPageLinks / AddSecondaryPageLinks), so no locking is needed.
|
||||||
|
private IntPtr _linkPdfiumDoc = IntPtr.Zero;
|
||||||
|
private string? _linkPdfiumDocPath;
|
||||||
|
|
||||||
|
/// <summary>Returns the cached PDFium handle for the current file, (re)opening it if the file
|
||||||
|
/// changed or it isn't open yet. Returns IntPtr.Zero if there is no file or the load fails.</summary>
|
||||||
|
private IntPtr EnsureLinkPdfiumDoc()
|
||||||
|
{
|
||||||
|
if (_currentFile is null) { CloseLinkPdfiumDoc(); return IntPtr.Zero; }
|
||||||
|
if (_linkPdfiumDoc != IntPtr.Zero && _linkPdfiumDocPath == _currentFile)
|
||||||
|
return _linkPdfiumDoc;
|
||||||
|
|
||||||
|
CloseLinkPdfiumDoc();
|
||||||
|
try { _ = DocLib.Instance; } catch { } // force Docnet to init PDFium before direct pdfium.dll calls
|
||||||
|
IntPtr doc = PdfiumInterop.FPDF_LoadDocument(_currentFile, null);
|
||||||
|
if (doc != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
_linkPdfiumDoc = doc;
|
||||||
|
_linkPdfiumDocPath = _currentFile;
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Closes the cached PDFium link handle if open. Called when the document changes or
|
||||||
|
/// closes; the path check in EnsureLinkPdfiumDoc is the backstop for anything not closed here.</summary>
|
||||||
|
private void CloseLinkPdfiumDoc()
|
||||||
|
{
|
||||||
|
if (_linkPdfiumDoc != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
try { PdfiumInterop.FPDF_CloseDocument(_linkPdfiumDoc); } catch { }
|
||||||
|
_linkPdfiumDoc = IntPtr.Zero;
|
||||||
|
}
|
||||||
|
_linkPdfiumDocPath = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a page's link annotations via PDFium (handles object-stream PDFs that PdfSharpCore
|
||||||
|
/// cannot). Returns the same canvas-space LinkInfo list as GetPageLinks, with AnnotIndex = -1
|
||||||
|
/// because the native annotation isn't addressable through PdfSharpCore's /Annots array - so
|
||||||
|
/// "Remove Link from PDF" is not offered for these.
|
||||||
|
/// </summary>
|
||||||
|
private List<LinkInfo> GetPageLinksViaPdfium(int pageIndex, int bitmapW, int bitmapH)
|
||||||
|
{
|
||||||
|
var links = new List<LinkInfo>();
|
||||||
|
|
||||||
|
// Reuse the PDFium handle cached per document (EnsureLinkPdfiumDoc) instead of reloading the
|
||||||
|
// whole file on every annotated page - object-stream PDFs take this path on every page, so a
|
||||||
|
// per-call FPDF_LoadDocument would re-parse the file once per page during a render sweep. The
|
||||||
|
// page itself is still loaded/closed per call; only the document handle is shared.
|
||||||
|
IntPtr doc = EnsureLinkPdfiumDoc();
|
||||||
|
if (doc == IntPtr.Zero) return links;
|
||||||
|
|
||||||
|
IntPtr page = PdfiumInterop.FPDF_LoadPage(doc, pageIndex);
|
||||||
|
if (page == IntPtr.Zero) return links;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
double pageWidthPt = PdfiumInterop.FPDF_GetPageWidth(page);
|
||||||
|
double pageHeightPt = PdfiumInterop.FPDF_GetPageHeight(page);
|
||||||
|
if (pageWidthPt <= 0) pageWidthPt = 595.28;
|
||||||
|
if (pageHeightPt <= 0) pageHeightPt = 841.89;
|
||||||
|
|
||||||
|
int startPos = 0;
|
||||||
|
while (PdfiumInterop.FPDFLink_Enumerate(page, ref startPos, out IntPtr link))
|
||||||
|
{
|
||||||
|
if (!PdfiumInterop.FPDFLink_GetAnnotRect(link, out PdfiumInterop.FS_RECTF r)) continue;
|
||||||
|
|
||||||
|
// PDFium may report top/bottom in either order; normalize to min/max so the
|
||||||
|
// mapping matches GetPageLinks (PDF origin is bottom-left, y up).
|
||||||
|
double rx1 = Math.Min(r.left, r.right);
|
||||||
|
double rx2 = Math.Max(r.left, r.right);
|
||||||
|
double ry1 = Math.Min(r.top, r.bottom);
|
||||||
|
double ry2 = Math.Max(r.top, r.bottom);
|
||||||
|
|
||||||
|
var (cx, cy, cw, ch) = PdfRectToCanvas(rx1, ry1, rx2, ry2, pageWidthPt, pageHeightPt, bitmapW, bitmapH);
|
||||||
|
if (cw < 1 || ch < 1) continue;
|
||||||
|
|
||||||
|
int? targetPage = null;
|
||||||
|
string? uri = null;
|
||||||
|
|
||||||
|
IntPtr dest = PdfiumInterop.FPDFLink_GetDest(doc, link);
|
||||||
|
if (dest != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
int t = PdfiumInterop.FPDFDest_GetDestPageIndex(doc, dest);
|
||||||
|
if (t >= 0) targetPage = t;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
IntPtr action = PdfiumInterop.FPDFLink_GetAction(link);
|
||||||
|
if (action != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
uint at = PdfiumInterop.FPDFAction_GetType(action);
|
||||||
|
if (at == PDFACTION_URI)
|
||||||
|
{
|
||||||
|
uint len = PdfiumInterop.FPDFAction_GetURIPath(doc, action, null, 0);
|
||||||
|
if (len > 1)
|
||||||
|
{
|
||||||
|
var buf = new byte[len];
|
||||||
|
PdfiumInterop.FPDFAction_GetURIPath(doc, action, buf, len);
|
||||||
|
uri = System.Text.Encoding.UTF8.GetString(buf, 0, (int)len - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (at == PDFACTION_GOTO)
|
||||||
|
{
|
||||||
|
IntPtr d2 = PdfiumInterop.FPDFAction_GetDest(doc, action);
|
||||||
|
if (d2 != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
int t = PdfiumInterop.FPDFDest_GetDestPageIndex(doc, d2);
|
||||||
|
if (t >= 0) targetPage = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetPage is null && string.IsNullOrEmpty(uri)) continue;
|
||||||
|
|
||||||
|
object tag = targetPage.HasValue ? (object)targetPage.Value : uri!;
|
||||||
|
string tip = targetPage.HasValue ? $"Go to page {targetPage.Value + 1}" : uri!;
|
||||||
|
links.Add(new LinkInfo(cx, cy, cw, ch, tag, tip, -1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { PdfiumInterop.FPDF_ClosePage(page); }
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders link overlays for the primary page onto the annotation canvas.
|
||||||
|
/// Uses a manual bounds-check in Canvas_MouseLeftButtonDown for hit detection
|
||||||
|
/// (transparent Canvas children are unreliable for WPF hit-testing alone).
|
||||||
|
/// </summary>
|
||||||
|
internal void RenderPageLinks(int pageIndex, int bitmapW, int bitmapH)
|
||||||
|
{
|
||||||
|
if (_doc is null || _currentFile is null) return;
|
||||||
|
|
||||||
|
var links = GetPageLinks(pageIndex, bitmapW, bitmapH);
|
||||||
|
foreach (var lnk in links)
|
||||||
|
{
|
||||||
|
var info = new LinkAnnotInfo(lnk.Tag, pageIndex, lnk.AnnotIndex);
|
||||||
|
// Grow the overlay by LinkHitPad on every side so the hand cursor, right-click menu, and the
|
||||||
|
// click bounds-check all share the padded hit area the tiled views use - thin one-line link
|
||||||
|
// strips are easy to hit in single-page view too.
|
||||||
|
var overlay = new Canvas
|
||||||
|
{
|
||||||
|
Width = lnk.Cw + LinkHitPad * 2,
|
||||||
|
Height = lnk.Ch + LinkHitPad * 2,
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
Cursor = Cursors.Hand,
|
||||||
|
ToolTip = lnk.Tip,
|
||||||
|
Tag = info,
|
||||||
|
IsHitTestVisible = true,
|
||||||
|
};
|
||||||
|
Canvas.SetLeft(overlay, lnk.Cx - LinkHitPad);
|
||||||
|
Canvas.SetTop(overlay, lnk.Cy - LinkHitPad);
|
||||||
|
|
||||||
|
// Right-click menu: same actions as the tiled-view canvas menu, from the shared builder.
|
||||||
|
var cm = new ContextMenu();
|
||||||
|
if (TryFindResource(typeof(ContextMenu)) is Style menuStyle) cm.Style = menuStyle;
|
||||||
|
TextOptions.SetTextFormattingMode(cm, TextFormattingMode.Display);
|
||||||
|
TextOptions.SetTextRenderingMode(cm, TextRenderingMode.Grayscale);
|
||||||
|
AddLinkMenuItems(cm, lnk.Tag, lnk.AnnotIndex, pageIndex);
|
||||||
|
if (cm.Items.Count > 0) overlay.ContextMenu = cm;
|
||||||
|
|
||||||
|
_annotationCanvas.Children.Add(overlay);
|
||||||
|
_linkOverlays.Add(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (links.Count > 0)
|
||||||
|
SetStatus(string.Format(Loc("Str_PageOfLinks"), pageIndex + 1, _doc.PageCount, links.Count));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a native PDF link annotation from the page /Annots array and persists the change.
|
||||||
|
/// Called from the "Remove Link from PDF" context-menu item on link overlays.
|
||||||
|
/// </summary>
|
||||||
|
private void RemoveLinkAnnotation(int pageIndex, int annotIndex)
|
||||||
|
{
|
||||||
|
if (_doc is null || pageIndex >= _doc.PageCount || annotIndex < 0) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pdfPage = _doc.Pages[pageIndex];
|
||||||
|
var annotsArr = pdfPage.Elements.GetArray("/Annots");
|
||||||
|
if (annotsArr is null || annotIndex >= annotsArr.Elements.Count) return;
|
||||||
|
|
||||||
|
// Neutralize the annotation object before removing the /Annots reference.
|
||||||
|
// If PdfSharpCore writes the orphaned indirect object to the output file,
|
||||||
|
// aggressive PDF viewers that scan cross-reference tables directly (rather
|
||||||
|
// than following /Annots) would still trigger the link without this step.
|
||||||
|
PdfItem? elem = annotsArr.Elements[annotIndex];
|
||||||
|
PdfDictionary? ann = elem as PdfDictionary ?? DerefItem(elem) as PdfDictionary;
|
||||||
|
if (ann != null)
|
||||||
|
{
|
||||||
|
ann.Elements.Remove("/A");
|
||||||
|
ann.Elements.Remove("/Dest");
|
||||||
|
ann.Elements.Remove("/Subtype");
|
||||||
|
}
|
||||||
|
|
||||||
|
annotsArr.Elements.RemoveAt(annotIndex);
|
||||||
|
MarkDirty();
|
||||||
|
SaveTempAndReload();
|
||||||
|
// Refresh the current page view so the overlay disappears.
|
||||||
|
int sel = _currentPage;
|
||||||
|
_currentPage = -1;
|
||||||
|
_currentPage = sel;
|
||||||
|
SetStatus(Loc("Str_LinkRemoved"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
KillerDialog.Show(Host!.Window, $"{Loc("Str_LinkRemoveFailed")}\n{ex.Message}", "KillerPDF",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StripLinkAnnotationBorders lives in Services/PdfScrub.cs (KillerUI refactor), beside
|
||||||
|
// the other pre-save scrubs it always runs with.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a page's link rectangles for the tiled views (continuous, grid, two-page). No
|
||||||
|
/// clickable overlay is created: in the tiled layout a per-link overlay swallows the click
|
||||||
|
/// but its own handler never fires, so clicks and the hover cursor are resolved by bounds-
|
||||||
|
/// testing these rects in Canvas_MouseLeftButtonDown and Canvas_MouseMove instead.
|
||||||
|
/// </summary>
|
||||||
|
internal void AddSecondaryPageLinks(int pageIndex, int bitmapW, int bitmapH)
|
||||||
|
{
|
||||||
|
_continuousLinks[pageIndex] = GetPageLinks(pageIndex, bitmapW, bitmapH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a /Dest value (PdfArray, PdfString, or PdfName) to a 0-based page index.
|
||||||
|
/// Returns null if the destination cannot be resolved.
|
||||||
|
/// Note: PdfReference is internal to PdfSharpCore so we use reflection for ObjectNumber
|
||||||
|
/// and var-inferred types instead of the type name.
|
||||||
|
/// </summary>
|
||||||
|
private int? ResolveDest(PdfItem? destItem)
|
||||||
|
{
|
||||||
|
if (destItem is null || _doc is null) return null;
|
||||||
|
|
||||||
|
// Dereference indirect object if needed (PdfReference is internal, use duck-typing).
|
||||||
|
destItem = DerefItem(destItem);
|
||||||
|
|
||||||
|
PdfArray? arr = null;
|
||||||
|
|
||||||
|
if (destItem is PdfArray a)
|
||||||
|
{
|
||||||
|
arr = a;
|
||||||
|
}
|
||||||
|
else if (destItem is PdfString || destItem is PdfName)
|
||||||
|
{
|
||||||
|
// Named destination - look up in the document catalog
|
||||||
|
arr = ResolveNamedDest(destItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arr is null || arr.Elements.Count == 0) return null;
|
||||||
|
|
||||||
|
// First element of the destination array is an indirect page reference.
|
||||||
|
// PdfReference.ObjectNumber is public but its type is internal; use reflection.
|
||||||
|
var pageRefItem = arr.Elements[0];
|
||||||
|
int elemObjNum = PdfScrub.GetObjectNumber(pageRefItem);
|
||||||
|
if (elemObjNum > 0)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < _doc.PageCount; i++)
|
||||||
|
{
|
||||||
|
// PdfPage.Reference (public) gives us access to ObjectNumber
|
||||||
|
var pgRef = _doc.Pages[i].Reference;
|
||||||
|
if (pgRef != null && pgRef.ObjectNumber == elemObjNum)
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (pageRefItem is PdfInteger pageInt)
|
||||||
|
{
|
||||||
|
int pn = pageInt.Value;
|
||||||
|
if (pn >= 0 && pn < _doc.PageCount) return pn;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a named destination (string or name) to a destination array using the
|
||||||
|
/// catalog's /Dests dictionary or /Names /Dests name tree.
|
||||||
|
/// </summary>
|
||||||
|
private PdfArray? ResolveNamedDest(PdfItem nameItem)
|
||||||
|
{
|
||||||
|
if (_doc is null) return null;
|
||||||
|
string name = nameItem switch
|
||||||
|
{
|
||||||
|
PdfString s => s.Value,
|
||||||
|
PdfName n => n.Value.TrimStart('/'),
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
if (string.IsNullOrEmpty(name)) return null;
|
||||||
|
|
||||||
|
var catalog = _doc.Internals.Catalog;
|
||||||
|
|
||||||
|
// Legacy /Dests dictionary (direct mapping)
|
||||||
|
var dests = catalog.Elements.GetDictionary("/Dests");
|
||||||
|
if (dests != null)
|
||||||
|
{
|
||||||
|
PdfItem? val = DerefItem(dests.Elements[name] ?? dests.Elements["/" + name] ?? new PdfInteger(-1));
|
||||||
|
if (val is PdfArray da) return da;
|
||||||
|
if (val is PdfDictionary dd) return dd.Elements.GetArray("/D");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modern /Names /Dests name tree
|
||||||
|
var names = catalog.Elements.GetDictionary("/Names");
|
||||||
|
var destTree = names?.Elements.GetDictionary("/Dests");
|
||||||
|
if (destTree != null)
|
||||||
|
return ResolveNameTree(destTree, name);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Walks a PDF name tree to find the destination array for the given name.
|
||||||
|
/// </summary>
|
||||||
|
private static PdfArray? ResolveNameTree(PdfDictionary node, string name)
|
||||||
|
{
|
||||||
|
// Leaf node: flat /Names array [key val key val ...]
|
||||||
|
var namesArr = node.Elements.GetArray("/Names");
|
||||||
|
if (namesArr != null)
|
||||||
|
{
|
||||||
|
for (int i = 0; i + 1 < namesArr.Elements.Count; i += 2)
|
||||||
|
{
|
||||||
|
var key = namesArr.Elements[i];
|
||||||
|
string keyStr = key is PdfString ks ? ks.Value : key?.ToString() ?? "";
|
||||||
|
if (keyStr == name)
|
||||||
|
{
|
||||||
|
PdfItem? val = Services.PdfScrub.DerefItemStatic(namesArr.Elements[i + 1]);
|
||||||
|
if (val is PdfArray va) return va;
|
||||||
|
if (val is PdfDictionary vd) return vd.Elements.GetArray("/D");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intermediate node: recurse into /Kids
|
||||||
|
var kids = node.Elements.GetArray("/Kids");
|
||||||
|
if (kids != null)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < kids.Elements.Count; i++)
|
||||||
|
{
|
||||||
|
PdfItem? kid = Services.PdfScrub.DerefItemStatic(kids.Elements[i]);
|
||||||
|
if (kid is PdfDictionary kd)
|
||||||
|
{
|
||||||
|
var result = ResolveNameTree(kd, name);
|
||||||
|
if (result != null) return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Animation;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using System.Windows.Shapes;
|
||||||
|
using Docnet.Core;
|
||||||
|
using Docnet.Core.Models;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using PdfSharpCore.Drawing;
|
||||||
|
using PdfSharpCore.Pdf;
|
||||||
|
using PdfSharpCore.Pdf.IO;
|
||||||
|
using KillerPDF.Services;
|
||||||
|
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
|
||||||
|
|
||||||
|
namespace KillerPDF.Controls
|
||||||
|
{
|
||||||
|
// Moved from Shell/PageSelection.cs; the namespace and class line are the only changes. Window
|
||||||
|
// members spelled bare here resolve through PdfViewer.Bridge.cs.
|
||||||
|
public partial class PdfViewer
|
||||||
|
{
|
||||||
|
// ============================================================
|
||||||
|
// Page selection handler
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
private void PageList_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
|
||||||
|
{
|
||||||
|
// Same speed knob as the document viewport (WheelScrollFactor in Zoom.cs).
|
||||||
|
Host?.ScrollSidebar(this, -e.Delta * (48.0 / 120.0) * Controls.PdfViewer.WheelScrollFactor);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PageJumpBox_KeyDown(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key != Key.Enter || _doc is null) return;
|
||||||
|
e.Handled = true;
|
||||||
|
if (int.TryParse(Host?.PageJumpText, out int pg))
|
||||||
|
{
|
||||||
|
int idx = Math.Max(0, Math.Min(_doc.PageCount - 1, pg - 1));
|
||||||
|
RecordNavJump(); // Alt+Left retraces the typed jump
|
||||||
|
_currentPage = idx;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Restore current page number if input was invalid
|
||||||
|
if (Host != null) Host.PageJumpText = (_currentPage + 1).ToString();
|
||||||
|
}
|
||||||
|
Keyboard.ClearFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PageJumpBox_GotFocus(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Host?.SelectAllPageJumpText();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Set by SyncCurrentPageTo (and only it) around its programmatic SelectedIndex
|
||||||
|
/// write, so the scroll-driven sync does not re-enter the render path through the window's
|
||||||
|
/// XAML-bound handler. Replaces the old detach/attach of a cached delegate, which never
|
||||||
|
/// worked: the list's real subscription is the WINDOW stub, so the -= removed nothing and
|
||||||
|
/// the += accumulated direct subscriptions (2026-08-01).</summary>
|
||||||
|
private bool _syncingPageList;
|
||||||
|
|
||||||
|
private void PageList_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_syncingPageList) return; // programmatic sync, not a user selection
|
||||||
|
// The sidebar page list is WINDOW chrome - there is one of it, and BOTH panes attach
|
||||||
|
// their own handler to it. It describes the focused pane only, so the other pane has
|
||||||
|
// to sit this out. Scrolling a pane calls SyncCurrentPageTo, which detaches its OWN
|
||||||
|
// handler before setting SelectedIndex to avoid re-entering the render path; the other
|
||||||
|
// pane's handler stayed attached and navigated that pane to the page you had just
|
||||||
|
// scrolled to in this one, which is why scrolling pane A scrolled pane B.
|
||||||
|
if (Host != null && !Host.IsViewerFocused(this)) return;
|
||||||
|
|
||||||
|
// Mirror the sidebar into the view's own current page. Set
|
||||||
|
// BEFORE the >= 0 guard on purpose - clearing the list (tab close, document close)
|
||||||
|
// drops SelectedIndex to -1 and that has to be mirrored too, or a closed document
|
||||||
|
// leaves a stale page number behind. Assigns _view.CurrentPage directly rather than
|
||||||
|
// going through _currentPage, whose setter would write back into PageList and re-enter
|
||||||
|
// this handler.
|
||||||
|
State.CurrentPage = (sender as ListBox)?.SelectedIndex ?? -1;
|
||||||
|
|
||||||
|
if (_currentPage >= 0)
|
||||||
|
{
|
||||||
|
CommitActiveTextBox();
|
||||||
|
ClearSelection();
|
||||||
|
ClearTextSelection();
|
||||||
|
Host?.EnsureSidebarPageVisible(this, _currentPage);
|
||||||
|
if (_viewMode == ViewMode.Continuous)
|
||||||
|
{
|
||||||
|
if (Host != null) Host.PageJumpText = (_currentPage + 1).ToString();
|
||||||
|
ScrollContinuousToPage(_currentPage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_viewMode == ViewMode.Grid)
|
||||||
|
{
|
||||||
|
// Grid is a stable overview: selecting a page highlights it but must NOT
|
||||||
|
// re-anchor the grid. It still needs an initial render (open / first display)
|
||||||
|
// when no tiles exist yet; later selections only update the highlight.
|
||||||
|
if (Host != null) Host.PageJumpText = (_currentPage + 1).ToString();
|
||||||
|
// Keep the statusbar counter honest even when the clicked tile is already in
|
||||||
|
// view (BringIntoView then scrolls nothing, so the scroll-sync never fires).
|
||||||
|
SetStatus(string.Format(Loc("Str_PageOf"), _currentPage + 1, _doc!.PageCount) + $" - {DisplayZoomPct():F0}%");
|
||||||
|
if (_pageContentPanel.Children.Count <= 1)
|
||||||
|
{
|
||||||
|
PagePreviewPanel.ScrollToTop();
|
||||||
|
PagePreviewPanel.ScrollToHorizontalOffset(0);
|
||||||
|
RenderPage(0); // grid primary is always page 0
|
||||||
|
// Default the grid to a clean 3-columns-across fit. Deferred to Loaded so the
|
||||||
|
// viewport width is valid (it can still be 0 mid-open, which would fall back
|
||||||
|
// to a carried-over zoom and show a single large page).
|
||||||
|
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded,
|
||||||
|
(Action)(() => SetZoom(GridZoomForN(Math.Min(_doc?.PageCount ?? 1, 3)))));
|
||||||
|
}
|
||||||
|
else if (_currentPage < _pageContentPanel.Children.Count
|
||||||
|
&& _pageContentPanel.Children[_currentPage] is FrameworkElement gridTile)
|
||||||
|
{
|
||||||
|
// Scroll the chosen page's tile into view (BringIntoView accounts for the zoom transform).
|
||||||
|
gridTile.BringIntoView();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Clicking either page of the spread that's already shown (or re-selecting the
|
||||||
|
// current single page) renders the exact same pixels, so skip the re-render and its
|
||||||
|
// flash - just move the page number. SpreadStart, NOT a local % 2: this was the
|
||||||
|
// fourth pairing site and the one #193's book layout missed - its stale (0,1)
|
||||||
|
// pairing matched the rendered cover and swallowed the render of spread (1,2).
|
||||||
|
int targetPrimary = _currentPage;
|
||||||
|
if (_viewMode == ViewMode.TwoPage) targetPrimary = SpreadStart(targetPrimary);
|
||||||
|
if (targetPrimary == _renderedPrimaryPage && Math.Abs(_zoomLevel - _lastRenderZoom) < 0.0001)
|
||||||
|
{
|
||||||
|
if (Host != null) Host.PageJumpText = (_currentPage + 1).ToString();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PagePreviewPanel.ScrollToTop();
|
||||||
|
PagePreviewPanel.ScrollToHorizontalOffset(0);
|
||||||
|
RenderPage(_currentPage);
|
||||||
|
ApplyZoom();
|
||||||
|
// Update page jump box
|
||||||
|
if (Host != null) Host.PageJumpText = (_currentPage + 1).ToString();
|
||||||
|
// Re-highlight search results on this page if a search is active
|
||||||
|
if (_searchBar is not null && _searchBar.Visibility == Visibility.Visible
|
||||||
|
&& Search.HasResults)
|
||||||
|
HighlightSearchResultsOnCurrentPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShortcutHelp_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (ShortcutOverlay.Visibility == Visibility.Visible) FadeOverlayOut(ShortcutOverlay);
|
||||||
|
else ShowShortcutsOverlayExclusive();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShortcutOverlay_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
// Click on the dim backdrop closes the overlay.
|
||||||
|
FadeOverlayOut(ShortcutOverlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShortcutOverlayCard_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
// Stop the click from bubbling up to the backdrop handler.
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShortcutOverlayClose_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
FadeOverlayOut(ShortcutOverlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Hyperlink_RequestNavigate(object sender, System.Windows.Navigation.RequestNavigateEventArgs e)
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true });
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Animation;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using System.Windows.Shapes;
|
||||||
|
using Docnet.Core;
|
||||||
|
using Docnet.Core.Models;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using PdfSharpCore.Drawing;
|
||||||
|
using PdfSharpCore.Pdf;
|
||||||
|
using PdfSharpCore.Pdf.IO;
|
||||||
|
using KillerPDF.Services;
|
||||||
|
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
|
||||||
|
|
||||||
|
namespace KillerPDF.Controls
|
||||||
|
{
|
||||||
|
// Moved from Shell/Selection.cs; the namespace and class line are the only changes. Window
|
||||||
|
// members spelled bare here resolve through PdfViewer.Bridge.cs.
|
||||||
|
public partial class PdfViewer
|
||||||
|
{
|
||||||
|
// ============================================================
|
||||||
|
// Selection
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Resolve the active theme's "SelectionAccent" color: a per-theme color picked to stay
|
||||||
|
// readable on the white PDF page (Accent is white in several themes, and AccentBorder is a
|
||||||
|
// pale cream that washes out on white). Falls back to brand green.
|
||||||
|
private Color AccentColor()
|
||||||
|
=> TryFindResource("SelectionAccent") is SolidColorBrush b ? b.Color : Color.FromRgb(30, 165, 76);
|
||||||
|
internal SolidColorBrush AccentBrush(byte alpha = 255)
|
||||||
|
{
|
||||||
|
var c = AccentColor();
|
||||||
|
return new SolidColorBrush(Color.FromArgb(alpha, c.R, c.G, c.B));
|
||||||
|
}
|
||||||
|
// A darker shade of the accent, used for a cover's selection chrome and its in-edit outline so a
|
||||||
|
// cover reads as distinct from the lighter accent on the text box stacked over it.
|
||||||
|
private SolidColorBrush DarkerAccentBrush(byte alpha = 255)
|
||||||
|
{
|
||||||
|
var c = AccentColor();
|
||||||
|
return new SolidColorBrush(Color.FromArgb(alpha, (byte)(c.R * 0.6), (byte)(c.G * 0.6), (byte)(c.B * 0.6)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Flowing text selection (#127)
|
||||||
|
// ============================================================
|
||||||
|
// A drag that STARTS on a text character tracks the actual run of characters in reading
|
||||||
|
// order, browser-style, instead of the rectangle marquee. Geometry comes from
|
||||||
|
// TextRunService (PdfPig words, the same source as search), endpoints are caret positions
|
||||||
|
// (page, 0..N over the page's flattened chars), and the painted quads use the exact
|
||||||
|
// PDF-to-render math AddSearchHighlight uses, so everything lands where search lands.
|
||||||
|
// Drags that start on empty page keep the classic marquee (annotation box-select,
|
||||||
|
// region copy, OCR region) - see Canvas_MouseLeftButtonDown.
|
||||||
|
|
||||||
|
private readonly TextRunService _textRuns = new();
|
||||||
|
private bool _txtSelActive; // drag in progress
|
||||||
|
private bool _txtSelHasRange; // a committed selection is on screen
|
||||||
|
private (int Page, int Caret) _txtSelAnchor;
|
||||||
|
private (int Page, int Caret) _txtSelFocus;
|
||||||
|
private Point _txtSelDownPos; // press point (gesture canvas coords)
|
||||||
|
private bool _txtSelDragStarted; // true once movement exceeds the click threshold
|
||||||
|
private PageAnnotation? _txtSelClickAnnot; // annotation under the press; selected on plain click
|
||||||
|
private Rect _txtSelClickAnnotBounds;
|
||||||
|
private EditTool? _txtSelCommitTool; // #127 Phase 2: non-null while a Highlight/Strike/
|
||||||
|
// Underline tool owns the flowing drag - the release
|
||||||
|
// commits annotations instead of copying text
|
||||||
|
|
||||||
|
/// <summary>Canvas point to PDF space (points, bottom-left origin) - the inverse of the
|
||||||
|
/// mapping AddSearchHighlight paints with, same as ExtractTextFromRegion.</summary>
|
||||||
|
private static (double X, double Y) CanvasToPdf(Point pos, double renderW, double renderH, PageTextRuns runs)
|
||||||
|
=> (pos.X * runs.PdfWidth / renderW, runs.PdfHeight - pos.Y * runs.PdfHeight / renderH);
|
||||||
|
|
||||||
|
/// <summary>Called from the Select tool's mouse-down. Returns true (and arms the drag) only
|
||||||
|
/// when the press lands ON text; empty page falls through to the marquee.</summary>
|
||||||
|
private bool TryBeginTextSelection(int pageIdx, Point pos)
|
||||||
|
{
|
||||||
|
if (_currentFile is null) return false;
|
||||||
|
if (!_renderDims.TryGetValue(pageIdx, out var rd)) return false;
|
||||||
|
var runs = _textRuns.GetPage(_currentFile, pageIdx);
|
||||||
|
if (runs is null || runs.Chars.Count == 0) return false;
|
||||||
|
|
||||||
|
var (px, py) = CanvasToPdf(pos, rd.w, rd.h, runs);
|
||||||
|
if (!TextRunService.IsOverText(runs, px, py)) return false;
|
||||||
|
|
||||||
|
ClearTextSelection();
|
||||||
|
int caret = TextRunService.CaretFromPoint(runs, px, py);
|
||||||
|
_txtSelAnchor = _txtSelFocus = (pageIdx, caret);
|
||||||
|
_txtSelDownPos = pos;
|
||||||
|
_txtSelDragStarted = false;
|
||||||
|
_txtSelActive = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mouse-move while a flowing selection drag is live. Resolves which page the
|
||||||
|
/// pointer is over (cross-page tracking in Continuous, where every overlay is a live tile),
|
||||||
|
/// moves the focus caret, and repaints.</summary>
|
||||||
|
private void UpdateTextSelectionDrag(MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (_currentFile is null) return;
|
||||||
|
// Click-vs-drag threshold: below ~4px of movement this is still a click (which selects
|
||||||
|
// the annotation under the press, if any, on mouse-up) - not a text drag.
|
||||||
|
if (!_txtSelDragStarted)
|
||||||
|
{
|
||||||
|
var tcv = _gestureCanvas ?? _activeCanvas;
|
||||||
|
if (tcv is null) return;
|
||||||
|
var tp = e.GetPosition(tcv);
|
||||||
|
if (Math.Abs(tp.X - _txtSelDownPos.X) < 4 && Math.Abs(tp.Y - _txtSelDownPos.Y) < 4) return;
|
||||||
|
_txtSelDragStarted = true;
|
||||||
|
}
|
||||||
|
int page = _gesturePage;
|
||||||
|
Canvas? cv = _gestureCanvas ?? _activeCanvas;
|
||||||
|
|
||||||
|
if (_viewMode == ViewMode.Continuous)
|
||||||
|
{
|
||||||
|
foreach (var kv in _pages)
|
||||||
|
{
|
||||||
|
var c = kv.Value;
|
||||||
|
double cw = double.IsNaN(c.Width) ? c.ActualWidth : c.Width;
|
||||||
|
double ch = double.IsNaN(c.Height) ? c.ActualHeight : c.Height;
|
||||||
|
var p = e.GetPosition(c);
|
||||||
|
if (p.X >= 0 && p.X <= cw && p.Y >= 0 && p.Y <= ch) { page = kv.Key; cv = c; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cv is null) return;
|
||||||
|
|
||||||
|
// Clamp into the canvas so dragging past an edge clamps to the start/end of lines
|
||||||
|
// instead of losing the selection.
|
||||||
|
double cvW = double.IsNaN(cv.Width) ? cv.ActualWidth : cv.Width;
|
||||||
|
double cvH = double.IsNaN(cv.Height) ? cv.ActualHeight : cv.Height;
|
||||||
|
var pos = e.GetPosition(cv);
|
||||||
|
pos = new Point(Math.Max(0, Math.Min(cvW, pos.X)), Math.Max(0, Math.Min(cvH, pos.Y)));
|
||||||
|
|
||||||
|
if (!_renderDims.TryGetValue(page, out var rd)) return;
|
||||||
|
var runs = _textRuns.GetPage(_currentFile, page);
|
||||||
|
if (runs is null) return;
|
||||||
|
|
||||||
|
var (px, py) = CanvasToPdf(pos, rd.w, rd.h, runs);
|
||||||
|
var focus = (page, TextRunService.CaretFromPoint(runs, px, py));
|
||||||
|
if (focus == _txtSelFocus) return;
|
||||||
|
_txtSelFocus = focus;
|
||||||
|
RepaintTextSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mouse-up: commit the range, copy it (matching the app's existing
|
||||||
|
/// select-copies-immediately behavior), and leave the quads on screen.</summary>
|
||||||
|
private void FinishTextSelection()
|
||||||
|
{
|
||||||
|
_txtSelActive = false;
|
||||||
|
var clickAnnot = _txtSelClickAnnot;
|
||||||
|
var clickBounds = _txtSelClickAnnotBounds;
|
||||||
|
var commitTool = _txtSelCommitTool;
|
||||||
|
_txtSelClickAnnot = null;
|
||||||
|
_txtSelCommitTool = null;
|
||||||
|
if (!_txtSelDragStarted || _txtSelAnchor == _txtSelFocus)
|
||||||
|
{
|
||||||
|
// Plain click: the annotation under the press (e.g. a highlight box covering this
|
||||||
|
// paragraph) gets selected, exactly as it did before flowing selection existed.
|
||||||
|
// (Select tool only - a highlight-tool click just drops the empty gesture.)
|
||||||
|
ClearTextSelection();
|
||||||
|
if (commitTool is null && clickAnnot is not null) SelectAnnotation(clickAnnot, clickBounds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (commitTool is EditTool hlTool)
|
||||||
|
{
|
||||||
|
CommitFlowingHighlight(hlTool);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_txtSelHasRange = true;
|
||||||
|
|
||||||
|
int words;
|
||||||
|
_selectedText = BuildSelectedText(out words);
|
||||||
|
if (string.IsNullOrWhiteSpace(_selectedText))
|
||||||
|
{
|
||||||
|
SetStatus(Loc("Str_St_NoTextInSelection"));
|
||||||
|
ClearTextSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try { Clipboard.SetText(_selectedText); } catch { /* clipboard momentarily locked by another app */ }
|
||||||
|
SetStatus(string.Format(Loc("Str_St_CopiedWords"), words));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ((int Page, int Caret) Start, (int Page, int Caret) End) OrderedSelection()
|
||||||
|
{
|
||||||
|
var a = _txtSelAnchor;
|
||||||
|
var f = _txtSelFocus;
|
||||||
|
bool aFirst = a.Page < f.Page || (a.Page == f.Page && a.Caret <= f.Caret);
|
||||||
|
return aFirst ? (a, f) : (f, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The caret slice of the selection that falls on one page, or (0,0) when none.</summary>
|
||||||
|
private (int Start, int End) SelectionSliceForPage(int page, int charCount)
|
||||||
|
{
|
||||||
|
var (s, e) = OrderedSelection();
|
||||||
|
if (page < s.Page || page > e.Page) return (0, 0);
|
||||||
|
int start = page == s.Page ? s.Caret : 0;
|
||||||
|
int end = page == e.Page ? e.Caret : charCount;
|
||||||
|
return (start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildSelectedText(out int wordCount)
|
||||||
|
{
|
||||||
|
wordCount = 0;
|
||||||
|
if (_currentFile is null) return string.Empty;
|
||||||
|
var (s, e) = OrderedSelection();
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
for (int p = s.Page; p <= e.Page; p++)
|
||||||
|
{
|
||||||
|
var runs = _textRuns.GetPage(_currentFile, p);
|
||||||
|
if (runs is null || runs.Chars.Count == 0) continue;
|
||||||
|
var (start, end) = SelectionSliceForPage(p, runs.Chars.Count);
|
||||||
|
if (start >= end) continue;
|
||||||
|
string t = TextRunService.TextForRange(runs, start, end, out int w);
|
||||||
|
if (t.Length == 0) continue;
|
||||||
|
if (sb.Length > 0) sb.Append('\n');
|
||||||
|
sb.Append(t);
|
||||||
|
wordCount += w;
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Per-line rects (canvas render-dim space) of the current selection on one page -
|
||||||
|
/// one rect per line, first selected char to last, browser-style. Shared by the selection
|
||||||
|
/// quad painter and the flowing-highlight commit (#127 Phase 2) so a committed highlight
|
||||||
|
/// lands exactly where the drag preview showed it.</summary>
|
||||||
|
private List<Rect> SelectionLineRectsForPage(int page)
|
||||||
|
{
|
||||||
|
var result = new List<Rect>();
|
||||||
|
if (_currentFile is null) return result;
|
||||||
|
var (s, e) = OrderedSelection();
|
||||||
|
if (page < s.Page || page > e.Page) return result;
|
||||||
|
if (!_renderDims.TryGetValue(page, out var rd)) return result;
|
||||||
|
var runs = _textRuns.GetPage(_currentFile, page);
|
||||||
|
if (runs is null || runs.Chars.Count == 0) return result;
|
||||||
|
var (start, end) = SelectionSliceForPage(page, runs.Chars.Count);
|
||||||
|
if (start >= end) return result;
|
||||||
|
|
||||||
|
double sx = rd.w / runs.PdfWidth;
|
||||||
|
double sy = rd.h / runs.PdfHeight;
|
||||||
|
|
||||||
|
int i = start;
|
||||||
|
while (i < end)
|
||||||
|
{
|
||||||
|
var line = runs.Lines[runs.Chars[i].Line];
|
||||||
|
int segEnd = Math.Min(end, line.End);
|
||||||
|
|
||||||
|
// A selected caret slice runs left-to-right for LTR and right-to-left for RTL.
|
||||||
|
// Use its physical extremes rather than assuming the first glyph is on the left.
|
||||||
|
double left = runs.Chars.Skip(i).Take(segEnd - i).Min(c => c.Left);
|
||||||
|
double right = runs.Chars.Skip(i).Take(segEnd - i).Max(c => c.Right);
|
||||||
|
double h = (line.Top - line.Bottom) * sy;
|
||||||
|
double pad = h * 0.12; // a touch of breathing room; tighter than search's 0.30
|
||||||
|
|
||||||
|
result.Add(new Rect(left * sx, rd.h - line.Top * sy - pad,
|
||||||
|
Math.Max((right - left) * sx, 2), Math.Max(h + pad * 2, 2)));
|
||||||
|
i = segEnd;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Paints one page's selection quads onto its overlay. Called while dragging and
|
||||||
|
/// from the tail of RenderAllAnnotations so the quads survive re-renders, exactly like
|
||||||
|
/// search highlights do.</summary>
|
||||||
|
private void ApplyTextSelectionQuads(int page, Canvas canvas)
|
||||||
|
{
|
||||||
|
if (!_txtSelActive && !_txtSelHasRange) return;
|
||||||
|
foreach (var r in SelectionLineRectsForPage(page))
|
||||||
|
{
|
||||||
|
var rect = new Rectangle
|
||||||
|
{
|
||||||
|
Opacity = 60.0 / 255.0,
|
||||||
|
Width = r.Width,
|
||||||
|
Height = r.Height,
|
||||||
|
IsHitTestVisible = false,
|
||||||
|
Tag = "TextSelQuad"
|
||||||
|
};
|
||||||
|
// Live theme binding (net48 rule: a plain brush snapshot won't follow a theme
|
||||||
|
// switch) - the quads recolor the moment the theme or accent changes.
|
||||||
|
rect.SetResourceReference(Shape.FillProperty, "SelectionAccent");
|
||||||
|
Canvas.SetLeft(rect, r.X);
|
||||||
|
Canvas.SetTop(rect, r.Y);
|
||||||
|
canvas.Children.Add(rect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#127 Phase 2: turns the flowing selection into Highlight / Strikethrough /
|
||||||
|
/// Underline annotations - one per selected line, grouped per page so one gesture behaves
|
||||||
|
/// as one annotation (select, move, delete together), and one page-snapshot undo entry so
|
||||||
|
/// Ctrl+Z reverts the whole gesture in a single step.</summary>
|
||||||
|
private void CommitFlowingHighlight(EditTool tool)
|
||||||
|
{
|
||||||
|
var (s, e) = OrderedSelection();
|
||||||
|
var perPage = new List<(int Page, List<Rect> Rects)>();
|
||||||
|
for (int p = s.Page; p <= e.Page; p++)
|
||||||
|
{
|
||||||
|
var rects = SelectionLineRectsForPage(p);
|
||||||
|
if (rects.Count > 0) perPage.Add((p, rects));
|
||||||
|
}
|
||||||
|
if (perPage.Count == 0) { ClearTextSelection(); return; }
|
||||||
|
|
||||||
|
PushPagesSnapshotUndo(perPage.Select(pp => pp.Page));
|
||||||
|
var style = tool == EditTool.Strikethrough ? HighlightStyle.Strikethrough
|
||||||
|
: tool == EditTool.Underline ? HighlightStyle.Underline
|
||||||
|
: HighlightStyle.Fill;
|
||||||
|
int total = 0;
|
||||||
|
foreach (var (page, rects) in perPage)
|
||||||
|
{
|
||||||
|
// One group per page; a single-line highlight stays ungrouped.
|
||||||
|
string gid = rects.Count > 1 ? Guid.NewGuid().ToString("N") : "";
|
||||||
|
if (!_annotations.ContainsKey(page)) _annotations[page] = [];
|
||||||
|
foreach (var r in rects)
|
||||||
|
{
|
||||||
|
var ha = new HighlightAnnotation { PageIndex = page, Bounds = r, Style = style, GroupId = gid };
|
||||||
|
ha.SetColor(tool == EditTool.Highlight ? _highlightColor : _lineAnnotColor);
|
||||||
|
_annotations[page].Add(ha);
|
||||||
|
total++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MarkDirty();
|
||||||
|
ClearTextSelection();
|
||||||
|
foreach (var (page, _) in perPage) RenderAllAnnotations(page);
|
||||||
|
SetStatus(string.Format(Loc(style == HighlightStyle.Fill ? "Str_St_HighlightedLines" : style == HighlightStyle.Strikethrough ? "Str_St_StruckLines" : "Str_St_UnderlinedLines"), total));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Drops and repaints the quads on every page the selection touches.</summary>
|
||||||
|
private void RepaintTextSelection()
|
||||||
|
{
|
||||||
|
RemoveTextSelQuads();
|
||||||
|
var (s, e) = OrderedSelection();
|
||||||
|
for (int p = s.Page; p <= e.Page; p++)
|
||||||
|
{
|
||||||
|
var canvas = VisibleCanvasForPage(p);
|
||||||
|
if (canvas is not null) ApplyTextSelectionQuads(p, canvas);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveTextSelQuads()
|
||||||
|
{
|
||||||
|
foreach (var canvas in AllPageCanvases())
|
||||||
|
{
|
||||||
|
var toRemove = canvas.Children.OfType<Rectangle>()
|
||||||
|
.Where(r => r.Tag is string s && s == "TextSelQuad").ToList();
|
||||||
|
foreach (var r in toRemove)
|
||||||
|
canvas.Children.Remove(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ctrl+A: flowing select-all on the current page (quads over every line) and copy.</summary>
|
||||||
|
private void SelectAllText()
|
||||||
|
{
|
||||||
|
if (_currentFile is null) return;
|
||||||
|
int pageIdx = _currentPage;
|
||||||
|
if (pageIdx < 0) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var runs = _textRuns.GetPage(_currentFile, pageIdx);
|
||||||
|
if (runs is null || runs.Chars.Count == 0)
|
||||||
|
{
|
||||||
|
SetStatus(Loc("Str_St_NoTextOnPage"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ClearTextSelection();
|
||||||
|
_txtSelAnchor = (pageIdx, 0);
|
||||||
|
_txtSelFocus = (pageIdx, runs.Chars.Count);
|
||||||
|
_txtSelHasRange = true;
|
||||||
|
RepaintTextSelection();
|
||||||
|
|
||||||
|
_selectedText = TextRunService.TextForRange(runs, 0, runs.Chars.Count, out _);
|
||||||
|
if (string.IsNullOrWhiteSpace(_selectedText))
|
||||||
|
{
|
||||||
|
SetStatus(Loc("Str_St_NoTextOnPage"));
|
||||||
|
ClearTextSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Clipboard.SetText(_selectedText);
|
||||||
|
SetStatus(Loc("Str_St_SelectAllCopied"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
SetStatus(string.Format(Loc("Str_St_SelectAllError"), ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CopySelectedText()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(_selectedText))
|
||||||
|
{
|
||||||
|
Clipboard.SetText(_selectedText);
|
||||||
|
SetStatus(Loc("Str_St_Copied"));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SetStatus(Loc("Str_St_NoTextSelected"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void ClearTextSelection()
|
||||||
|
{
|
||||||
|
if (_selectRect is not null)
|
||||||
|
{
|
||||||
|
// Remove from the rect's ACTUAL parent. Since the cross-page marquee rework the
|
||||||
|
// selection box lives on the window-level MarqueeLayer, not the page canvas, so
|
||||||
|
// removing from _activeCanvas was a silent no-op that orphaned the box on the
|
||||||
|
// layer until app restart (#121).
|
||||||
|
(_selectRect.Parent as Canvas)?.Children.Remove(_selectRect);
|
||||||
|
_selectRect = null;
|
||||||
|
}
|
||||||
|
_selectedText = null;
|
||||||
|
_txtSelActive = false;
|
||||||
|
_txtSelHasRange = false;
|
||||||
|
_txtSelDragStarted = false;
|
||||||
|
_txtSelCommitTool = null;
|
||||||
|
RemoveTextSelQuads();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Marquee fallback: rectangle region copy, used when a drag starts on EMPTY page
|
||||||
|
/// (scans, margins). Kept word-box based on purpose - on pages with no text layer there is
|
||||||
|
/// nothing to flow along, and this is also what the annotation box-select falls back to.</summary>
|
||||||
|
private void ExtractTextFromRegion(int pageIdx, Rect canvasBounds)
|
||||||
|
{
|
||||||
|
if (_currentFile is null || pageIdx < 0) return;
|
||||||
|
if (!_renderDims.ContainsKey(pageIdx)) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (renderW, renderH) = _renderDims[pageIdx];
|
||||||
|
|
||||||
|
using var pigDoc = PdfPigDoc.Open(_currentFile);
|
||||||
|
if (pageIdx >= pigDoc.NumberOfPages) return;
|
||||||
|
var page = pigDoc.GetPage(pageIdx + 1); // PdfPig is 1-based
|
||||||
|
|
||||||
|
double pdfW = page.Width;
|
||||||
|
double pdfH = page.Height;
|
||||||
|
double sx = pdfW / renderW;
|
||||||
|
double sy = pdfH / renderH;
|
||||||
|
|
||||||
|
// Convert canvas rect to PDF coordinates (flip Y - PDF origin is bottom-left)
|
||||||
|
double pdfLeft = canvasBounds.Left * sx;
|
||||||
|
double pdfRight = canvasBounds.Right * sx;
|
||||||
|
double pdfTop = pdfH - (canvasBounds.Top * sy);
|
||||||
|
double pdfBottom = pdfH - (canvasBounds.Bottom * sy);
|
||||||
|
// pdfTop > pdfBottom because of Y flip
|
||||||
|
double pdfMinY = Math.Min(pdfTop, pdfBottom);
|
||||||
|
double pdfMaxY = Math.Max(pdfTop, pdfBottom);
|
||||||
|
|
||||||
|
var words = page.GetWords()
|
||||||
|
.Where(w =>
|
||||||
|
{
|
||||||
|
var bb = w.BoundingBox;
|
||||||
|
double cx = (bb.Left + bb.Right) / 2;
|
||||||
|
double cy = (bb.Bottom + bb.Top) / 2;
|
||||||
|
return cx >= pdfLeft && cx <= pdfRight && cy >= pdfMinY && cy <= pdfMaxY;
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (words.Count == 0)
|
||||||
|
{
|
||||||
|
SetStatus(Loc("Str_St_NoTextInSelection"));
|
||||||
|
ClearTextSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectedText = WordsToText(words);
|
||||||
|
|
||||||
|
Clipboard.SetText(_selectedText);
|
||||||
|
int wordCount = words.Count;
|
||||||
|
SetStatus(string.Format(Loc("Str_St_CopiedWords"), wordCount));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
SetStatus(string.Format(Loc("Str_St_ExtractError"), ex.Message));
|
||||||
|
ClearTextSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,723 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
|
||||||
|
namespace KillerPDF.Controls
|
||||||
|
{
|
||||||
|
// This pane's tab band: which tabs are in the strip, which of them owns an edge, what the card's
|
||||||
|
// top corners do, where the focus ring runs, and the drag that reorders them or hands one to the
|
||||||
|
// other pane.
|
||||||
|
//
|
||||||
|
// Ported from KillerShell (FilePane.xaml + Tabs.cs + DualPane.cs + PaneDrag.cs). The strip is an
|
||||||
|
// ItemsControl bound to _sessions over a UniformGrid, and every visual decision below is a
|
||||||
|
// NOTIFYING FLAG ON THE SESSION that a template trigger reads - not a property written onto a
|
||||||
|
// code-built Border. That is the whole point: there is one place each rule is expressed, so a
|
||||||
|
// fix to one edge case cannot break the next one.
|
||||||
|
//
|
||||||
|
// Two consequences worth knowing before changing anything here:
|
||||||
|
// * UniformGrid divides the band equally, so the last visible tab ALWAYS reaches the strip's
|
||||||
|
// right edge. Edge ownership is decided, never measured. The old strip measured it after
|
||||||
|
// every reflow, which is why the halo came and went with the pane width.
|
||||||
|
// * A collapsed child is not counted when UniformGrid divides the band, so windowing tabs out
|
||||||
|
// into the chevron needs no width arithmetic at all - the survivors fill the band on their own.
|
||||||
|
public partial class PdfViewer
|
||||||
|
{
|
||||||
|
/// <summary>Bind the strip to this pane's sessions. Called once, from InitSplitPanes.</summary>
|
||||||
|
private void InitTabStrip()
|
||||||
|
{
|
||||||
|
if (TabStrip != null) TabStrip.ItemsSource = _sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one funnel. Every add, close, switch, drag-reorder and resize ends here, and it is the
|
||||||
|
/// only thing that writes the strip's state.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Still called RebuildTabStrip because ~20 call sites and RebuildTabStripExt already say so,
|
||||||
|
/// but it rebuilds nothing: the ItemsControl repaints itself off the collection and the
|
||||||
|
/// notifying flags.
|
||||||
|
/// </remarks>
|
||||||
|
private void RebuildTabStrip()
|
||||||
|
{
|
||||||
|
if (TabStrip == null || TabStripBorder == null) return;
|
||||||
|
|
||||||
|
foreach (var t in _sessions) t.RefreshTabLabel();
|
||||||
|
|
||||||
|
int docTabs = _sessions.Count(t => t.Doc != null || t.DeferredPath != null);
|
||||||
|
// Only show the strip once THIS pane has more than one document - a single open PDF
|
||||||
|
// doesn't need tabs, split or not. (KillerShell forces the band on in both panes whenever
|
||||||
|
// either one has 2+ tabs, so the two card tops always line up; the simpler per-pane
|
||||||
|
// rule is used here instead, so a lone tab never shows a bar even while split.)
|
||||||
|
bool show = docTabs > 1;
|
||||||
|
TabStripBorder.Visibility = show ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
|
||||||
|
// The -1 tucks the card's top border a pixel into the band, so the active tab and the card
|
||||||
|
// read as one surface. ONLY while there IS a band: a collapsed element contributes no
|
||||||
|
// height, so with no strip the -1 lifts this pane a pixel above the other one instead of
|
||||||
|
// tucking under anything. The bandless card gets 3px of top air instead of 0 - at 0 it
|
||||||
|
// opened 3px too high against the chrome (2026-08-01).
|
||||||
|
CardRow.Margin = new Thickness(0, show ? -1 : 3, 0, 0);
|
||||||
|
|
||||||
|
// Win98 tabs sit on a raised client frame. Keep the frame's vertical sides and bottom;
|
||||||
|
// the one-pixel top ledge is drawn by TabBarRing so the active tab can cover its own
|
||||||
|
// segment while the ledge remains visible beneath the inactive tabs.
|
||||||
|
bool retroTheme = Services.ThemeManager.Current == Services.Theme.SE98;
|
||||||
|
bool retroTabs = show && retroTheme;
|
||||||
|
if (retroTheme)
|
||||||
|
{
|
||||||
|
PaneBevelOuterDark.SetResourceReference(Border.BorderBrushProperty, "DocumentPaneBevelTopLeftBrush");
|
||||||
|
PaneBevelOuterLight.SetResourceReference(Border.BorderBrushProperty, "DocumentPaneBevelBottomRightBrush");
|
||||||
|
PaneBevelInnerDark.SetResourceReference(Border.BorderBrushProperty, "DocumentPaneBevelInnerTopLeftBrush");
|
||||||
|
PaneBevelInnerLight.SetResourceReference(Border.BorderBrushProperty, "DocumentPaneBevelInnerBottomRightBrush");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
PaneBevelOuterDark.SetResourceReference(Border.BorderBrushProperty, "PaneBevelDarkBrush");
|
||||||
|
PaneBevelOuterLight.SetResourceReference(Border.BorderBrushProperty, "PaneBevelLightBrush");
|
||||||
|
PaneBevelInnerDark.SetResourceReference(Border.BorderBrushProperty, "PaneBevelDark2Brush");
|
||||||
|
PaneBevelInnerLight.SetResourceReference(Border.BorderBrushProperty, "PaneBevelLight2Brush");
|
||||||
|
}
|
||||||
|
if (retroTabs)
|
||||||
|
{
|
||||||
|
PaneBorder.BorderThickness = new Thickness(1, 0, 1, 1);
|
||||||
|
PaneBevelOuterDark.BorderThickness = new Thickness(1, 0, 0, 0);
|
||||||
|
PaneBevelOuterLight.BorderThickness = new Thickness(0, 0, 1, 1);
|
||||||
|
PaneBevelInnerDark.BorderThickness = new Thickness(1, 0, 0, 0);
|
||||||
|
// PaneBorder + the outer dark bevel are the complete Win98 right edge.
|
||||||
|
// A third same-color inner rule made the edge fat and produced a one-pixel
|
||||||
|
// step where the selected last tab joined it.
|
||||||
|
PaneBevelInnerLight.BorderThickness = new Thickness(0, 0, 0, 1);
|
||||||
|
}
|
||||||
|
else if (retroTheme)
|
||||||
|
{
|
||||||
|
// With no tab band there is nothing for a raised client frame to join. The old
|
||||||
|
// generic fallback below reapplied all four 98SE bevel resources and drew a heavy
|
||||||
|
// rectangle around the entire document pane. Keep the single-document client
|
||||||
|
// flush; the classic frame is only part of the multi-tab treatment above.
|
||||||
|
PaneBorder.BorderThickness = new Thickness(0);
|
||||||
|
PaneBevelOuterDark.BorderThickness = new Thickness(0);
|
||||||
|
PaneBevelOuterLight.BorderThickness = new Thickness(0);
|
||||||
|
PaneBevelInnerDark.BorderThickness = new Thickness(0);
|
||||||
|
PaneBevelInnerLight.BorderThickness = new Thickness(0);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
PaneBorder.BorderThickness = new Thickness(1);
|
||||||
|
PaneBevelOuterDark.SetResourceReference(Border.BorderThicknessProperty, "PaneBevelLightThickness");
|
||||||
|
PaneBevelOuterLight.SetResourceReference(Border.BorderThicknessProperty, "PaneBevelDarkThickness");
|
||||||
|
PaneBevelInnerDark.SetResourceReference(Border.BorderThicknessProperty, "PaneBevel2LightThickness");
|
||||||
|
PaneBevelInnerLight.SetResourceReference(Border.BorderThicknessProperty, "PaneBevel2DarkThickness");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which tabs fit at this width, before anything below asks which is on an edge.
|
||||||
|
ApplyTabWindow();
|
||||||
|
|
||||||
|
// First and last VISIBLE, not first and last in the list. Both are about the strip's own
|
||||||
|
// edges: IsLast drops the divider that would otherwise land on the right edge as a stray
|
||||||
|
// rule, and IsFirst/IsLast keep the tab from drawing the outer ring side that the band
|
||||||
|
// already draws. With tabs windowed out, the tab sitting on an edge is not the one at the
|
||||||
|
// end of the collection.
|
||||||
|
//
|
||||||
|
// And NOT the last visible tab while the chevron is showing: the chevron is what sits on
|
||||||
|
// the band's right edge then, so the tab is a middle tab in every way that matters. Told
|
||||||
|
// otherwise it dropped the divider that separates it from the chevron AND handed its right
|
||||||
|
// side to the band, which drew that side at the band's edge - past the chevron, as an
|
||||||
|
// accent stripe up the far right with nothing under it.
|
||||||
|
bool chevron = TabOverflowBtn.Visibility == Visibility.Visible;
|
||||||
|
|
||||||
|
var strip = _sessions.Where(t => t.IsStripVisible).ToList();
|
||||||
|
foreach (var t in _sessions)
|
||||||
|
{
|
||||||
|
t.IsFirst = false;
|
||||||
|
t.IsLast = false;
|
||||||
|
t.RetroBeforeActive = false;
|
||||||
|
t.RetroAfterActive = false;
|
||||||
|
t.RetroLastInactive = false;
|
||||||
|
t.UseRetroTabChrome = retroTabs;
|
||||||
|
}
|
||||||
|
if (strip.Count > 0)
|
||||||
|
{
|
||||||
|
strip[0].IsFirst = true;
|
||||||
|
strip[strip.Count - 1].IsLast = !chevron;
|
||||||
|
|
||||||
|
int activeIndex = strip.IndexOf(_active!);
|
||||||
|
if (retroTabs && activeIndex >= 0)
|
||||||
|
{
|
||||||
|
if (activeIndex > 0) strip[activeIndex - 1].RetroBeforeActive = true;
|
||||||
|
if (activeIndex + 1 < strip.Count) strip[activeIndex + 1].RetroAfterActive = true;
|
||||||
|
if (!chevron && !strip[^1].IsActive) strip[^1].RetroLastInactive = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncPaneLeadingCorner();
|
||||||
|
UpdatePaneFocusRing();
|
||||||
|
UpdateTabStripFade();
|
||||||
|
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)UpdateFooterFade);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// OVERFLOW
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// The strip is a UniformGrid, so every tab takes an equal share of the band whatever the
|
||||||
|
// count - right up to a point, and then off a cliff. Eight tabs in a half-width pane came out
|
||||||
|
// around forty pixels each, which is not a label, it is a shape. A tab you cannot read is a
|
||||||
|
// tab you have to click to identify, and at that point the strip has stopped being navigation.
|
||||||
|
//
|
||||||
|
// So the COUNT is capped rather than the width. As many tabs as fit at TabFloorWidth stay in
|
||||||
|
// the strip and the rest are collapsed. The chevron at the right end lists every tab, so
|
||||||
|
// nothing is unreachable.
|
||||||
|
//
|
||||||
|
// Scrolling was the other option and is what a browser does. It lost because the band is a
|
||||||
|
// bordered surface the pane's focus ring runs along, and a scrolled band cannot be edge to
|
||||||
|
// edge - the ring would have to stop somewhere that is not a corner.
|
||||||
|
|
||||||
|
/// <summary>Narrowest a tab may get before the strip stops taking more.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Picked from what it has to hold rather than off a grid: 120px is about sixteen characters
|
||||||
|
/// at this size once the close x and the padding are paid for - "Quarterly-Repo...", enough to
|
||||||
|
/// tell two documents apart. Much below a hundred and the ellipsis starts eating the part that
|
||||||
|
/// distinguishes them, which is the whole job.
|
||||||
|
/// </remarks>
|
||||||
|
private const double TabFloorWidth = 120;
|
||||||
|
|
||||||
|
/// <summary>What the chevron takes out of the band while it is showing.</summary>
|
||||||
|
private const double TabChevronWidth = 26;
|
||||||
|
|
||||||
|
/// <summary>Index of the leftmost tab currently in the strip. 0 whenever they all fit.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Per pane, like the sessions themselves: the two strips are different widths and hold
|
||||||
|
/// different numbers of tabs, so one shared index would have each pane scrolling the other.
|
||||||
|
/// </remarks>
|
||||||
|
private int _tabWindow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decide which of this pane's tabs are in the strip at its current width, and show or hide
|
||||||
|
/// the chevron. Called from RebuildTabStrip, before anything reads which tab is on an edge.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The window is a contiguous RUN, not a set: tabs keep their order and their neighbors, so a
|
||||||
|
/// strip that has moved still reads like the tab bar it was. It shifts the least it can to
|
||||||
|
/// keep the active tab on screen, which is the one invariant that matters - a tab you just
|
||||||
|
/// switched to and cannot see is worse than no strip at all.
|
||||||
|
/// </remarks>
|
||||||
|
private void ApplyTabWindow()
|
||||||
|
{
|
||||||
|
int n = _sessions.Count;
|
||||||
|
if (n == 0) { TabOverflowBtn.Visibility = Visibility.Collapsed; return; }
|
||||||
|
|
||||||
|
// ActualWidth is 0 until the band has been measured once - on the first pass, and on any
|
||||||
|
// pass that runs while the pane is hidden. Falling back to the pane's own width keeps the
|
||||||
|
// answer sane instead of capping the strip at one tab and having to be undone by the
|
||||||
|
// SizeChanged that follows.
|
||||||
|
double avail = TabStripBorder.ActualWidth > 0 ? TabStripBorder.ActualWidth : ActualWidth;
|
||||||
|
|
||||||
|
// Two passes, because the chevron's width changes the answer that decides whether there is
|
||||||
|
// a chevron. Asked without it first: if everything fits there is none, and the whole band
|
||||||
|
// belongs to the strip.
|
||||||
|
int cap = (int)(avail / TabFloorWidth);
|
||||||
|
bool overflow = cap < n;
|
||||||
|
if (overflow)
|
||||||
|
{
|
||||||
|
cap = Math.Max(1, (int)((avail - TabChevronWidth) / TabFloorWidth));
|
||||||
|
if (cap >= n) overflow = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
TabOverflowBtn.Visibility = overflow ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
|
||||||
|
int start = 0;
|
||||||
|
if (overflow)
|
||||||
|
{
|
||||||
|
// Clamped before the active tab is considered, so a window left pointing past the end
|
||||||
|
// by a close does not survive as a scroll nobody asked for.
|
||||||
|
start = Math.Max(0, Math.Min(_tabWindow, n - cap));
|
||||||
|
|
||||||
|
int active = _active == null ? -1 : _sessions.IndexOf(_active);
|
||||||
|
if (active >= 0 && active < start) start = active;
|
||||||
|
else if (active >= 0 && active > start + cap - 1) start = active - cap + 1;
|
||||||
|
|
||||||
|
_tabWindow = start;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_tabWindow = 0;
|
||||||
|
cap = n;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
_sessions[i].IsStripVisible = i >= start && i < start + cap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The band was resized, so the strip may hold a different number of tabs.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Goes through RebuildTabStrip rather than calling ApplyTabWindow alone: a different set of
|
||||||
|
/// visible tabs is a different first and last tab, and those are the card's corner rounding
|
||||||
|
/// and the focus ring's outer verticals as much as they are the strip.
|
||||||
|
/// </remarks>
|
||||||
|
private void TabBarResized()
|
||||||
|
{
|
||||||
|
if (_sessions.Count == 0 || _inTabResize) return;
|
||||||
|
|
||||||
|
// Reentrancy guard, not an optimization. RebuildTabStrip writes CardRow.Margin and flips
|
||||||
|
// the band's own Visibility, either of which can raise SizeChanged again from inside this
|
||||||
|
// call - and a layout loop in WPF is not a slow app, it is a hung one. The pass that
|
||||||
|
// follows would compute the same answer anyway.
|
||||||
|
_inTabResize = true;
|
||||||
|
try { RebuildTabStrip(); }
|
||||||
|
finally { _inTabResize = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _inTabResize;
|
||||||
|
|
||||||
|
private void TabStripBorder_SizeChanged(object sender, SizeChangedEventArgs e) => TabBarResized();
|
||||||
|
|
||||||
|
/// <summary>The chevron: every tab in this pane, hidden ones included, in strip order.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// EVERY tab, not only the overflowed ones. A list that shows just what is off screen makes
|
||||||
|
/// you work out which those are before you can use it, and the visible ones cost nothing to
|
||||||
|
/// include. Built on each open rather than kept: titles change on every save and load.
|
||||||
|
/// </remarks>
|
||||||
|
private void TabOverflow_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var menu = MakeThemedMenu();
|
||||||
|
foreach (var t in _sessions)
|
||||||
|
{
|
||||||
|
var sess = t;
|
||||||
|
// Doubled, because a lone underscore in a MenuItem header is an access-key marker:
|
||||||
|
// "Q3_Report" would draw as "Q3Report" with an R underlined, and file names carry
|
||||||
|
// underscores all the time.
|
||||||
|
var item = MakeMenuItem(sess.TabLabel.Replace("_", "__"), (_, _) => SwitchToTab(sess), glyph: "");
|
||||||
|
// Bold rather than a check mark: the menu has no icon column to put one in.
|
||||||
|
if (sess.IsActive) item.FontWeight = FontWeights.Bold;
|
||||||
|
menu.Items.Add(item);
|
||||||
|
}
|
||||||
|
menu.PlacementTarget = TabOverflowBtn;
|
||||||
|
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
|
||||||
|
menu.IsOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// CARD CORNERS + FOCUS RING
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Square off the card's top corners under a flush active tab.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Each top corner squares only when the tab sitting on it is the ACTIVE one: the first tab
|
||||||
|
/// owns the top-left, the last owns the top-right. That tab's outer edge is flat and flush
|
||||||
|
/// with the card's, so a curve underneath cuts a notch out from under a square tab. An
|
||||||
|
/// inactive tab is window-colored and so a different surface anyway, and the card keeps its
|
||||||
|
/// rounding under it; no strip at all and the card takes its full radius back.
|
||||||
|
///
|
||||||
|
/// Read off the MODEL, never re-measured from the visual tree. Skipped in full screen, where
|
||||||
|
/// ApplyFullScreen owns the radius and squares all four.
|
||||||
|
/// </remarks>
|
||||||
|
private void SyncPaneLeadingCorner()
|
||||||
|
{
|
||||||
|
// PaneBorder / PaneShadow, not DocPaneBorder / DocPaneShadow: those names are the
|
||||||
|
// window's forwards to the FOCUSED pane, and this runs for whichever pane's strip
|
||||||
|
// changed. Using them here squares pane A's corners when pane B's tabs move.
|
||||||
|
if (PaneBorder == null || _fullScreen) return;
|
||||||
|
double r = TryFindResource("RadCard") is CornerRadius rc ? rc.TopLeft : 6;
|
||||||
|
|
||||||
|
bool strip = TabStripBorder != null && TabStripBorder.Visibility == Visibility.Visible;
|
||||||
|
bool firstActive = strip && _active?.IsFirst == true;
|
||||||
|
bool lastActive = strip && _active?.IsLast == true;
|
||||||
|
|
||||||
|
var cr = new CornerRadius(firstActive ? 0 : r, lastActive ? 0 : r, r, r);
|
||||||
|
PaneBorder.CornerRadius = cr;
|
||||||
|
if (PaneShadow != null) PaneShadow.CornerRadius = cr;
|
||||||
|
// Keep the ring's top radii in step with the card's, so its curved sides land exactly on
|
||||||
|
// the card's own left/right border rather than beside them.
|
||||||
|
if (TabBarRing != null)
|
||||||
|
{
|
||||||
|
TabBarRing.CornerRadius = new CornerRadius(cr.TopLeft, cr.TopRight, 0, 0);
|
||||||
|
if (Services.ThemeManager.Current == Services.Theme.SE98)
|
||||||
|
{
|
||||||
|
// The raised pane's light top rule is the horizontal part of the selected-tab
|
||||||
|
// route. The selected tab covers its own segment; the remaining rule turns up
|
||||||
|
// at the tab sides and therefore reads as one continuous classic outline.
|
||||||
|
TabBarRing.BorderThickness = new Thickness(0, 1, 0, 0);
|
||||||
|
TabBarRing.SetResourceReference(Border.BorderBrushProperty, "BevelLightBrush");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Modern themes do not use the Win98 edge overlays, so the ring carries the
|
||||||
|
// side only where the active tab makes that card corner square.
|
||||||
|
TabBarRing.BorderThickness = new Thickness(firstActive ? 1 : 0, 1, lastActive ? 1 : 0, 0);
|
||||||
|
TabBarRing.SetResourceReference(Border.BorderBrushProperty, "PaneEdgeBrush");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mark this pane's focus state on its tabs and draw the ring's outer verticals.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The ring has to continue UP and AROUND the active tab, or it stops dead at the band and the
|
||||||
|
/// tab and card read as two surfaces. The tab's own share of that is a template trigger on
|
||||||
|
/// PaneFocused; all this does is set the flag.
|
||||||
|
///
|
||||||
|
/// PaneDimmed is the other half - the active tab of the pane that does NOT have focus drops
|
||||||
|
/// its lip to the card's border color, because two lips at full accent both claim to be the
|
||||||
|
/// live pane. Deliberately NOT !PaneFocused: with one pane open both are false and that pane's
|
||||||
|
/// lip stays bright.
|
||||||
|
///
|
||||||
|
/// The outermost verticals come from the BAND, not from the tab: a first or last tab's own
|
||||||
|
/// outer border sits on the ScrollViewer's clip edge and gets cut, so whether it survived
|
||||||
|
/// depended on how the UniformGrid divided a fractional band width. TabEdgeLeft/Right are
|
||||||
|
/// anchored to the band's own edges, which are the card's edges, so there is no arithmetic to
|
||||||
|
/// land wrong.
|
||||||
|
/// </remarks>
|
||||||
|
private void UpdatePaneFocusRing()
|
||||||
|
{
|
||||||
|
bool split = Host?.IsSplitView == true;
|
||||||
|
bool retro = Services.ThemeManager.Current == Services.Theme.SE98;
|
||||||
|
// Modern themes only need a focus ring when panes are split. 98SE uses the
|
||||||
|
// selected pane's surface color as its focus indicator, including single-pane mode.
|
||||||
|
bool paneActive = PaneHasFocus && (split || retro);
|
||||||
|
bool lit = PaneHasFocus && split && !retro;
|
||||||
|
|
||||||
|
// TabBarRing is the pane border's top segment inside the tab band. Corner syncing
|
||||||
|
// assigns its geometry and an idle brush, so focus must restore the live accent here
|
||||||
|
// every time the tab state is rebuilt. Without this assignment, the active tab and
|
||||||
|
// the vertical card edges lit up while the horizontal segments beside the tab stayed
|
||||||
|
// dark, leaving the focused-pane perimeter visibly broken.
|
||||||
|
if (TabBarRing != null && !retro)
|
||||||
|
TabBarRing.SetResourceReference(Border.BorderBrushProperty,
|
||||||
|
lit ? "TabActiveRingBrush" : "PaneEdgeBrush");
|
||||||
|
|
||||||
|
foreach (var t in _sessions)
|
||||||
|
{
|
||||||
|
// 98SE uses PaneFocused only for the shared darker pane/tab surface. Its focus
|
||||||
|
// thickness resources are zero, so this never revives the modern accent outline.
|
||||||
|
t.PaneFocused = paneActive && t.IsActive;
|
||||||
|
t.PaneDimmed = split && !paneActive && t.IsActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PaneBorder != null)
|
||||||
|
PaneBorder.SetResourceReference(Border.BackgroundProperty,
|
||||||
|
retro ? (paneActive ? "FocusedPaneBrush" : "TabInactiveBrush") : "BgCanvas");
|
||||||
|
if (PaneShadow != null)
|
||||||
|
PaneShadow.SetResourceReference(Border.BackgroundProperty,
|
||||||
|
retro ? (paneActive ? "FocusedPaneBrush" : "TabInactiveBrush") : "BgCanvas");
|
||||||
|
|
||||||
|
// Same ownership rule the card's corner rounding uses, read off the tab rather than
|
||||||
|
// recomputed: with the strip windowed the tab on an edge is not the one at the end of the
|
||||||
|
// list, and two places working that out separately is two places to get it wrong.
|
||||||
|
bool firstActive = _active?.IsFirst == true;
|
||||||
|
bool lastActive = _active?.IsLast == true;
|
||||||
|
bool firstInactiveRetro = retro && _sessions.Any(t => t.IsStripVisible && t.IsFirst && !t.IsActive);
|
||||||
|
bool lastInactiveRetro = retro && _sessions.Any(t => t.RetroLastInactive);
|
||||||
|
// The XAML declares these with NO Background - unlike KillerShell's copy, which paints
|
||||||
|
// them PrimaryBrush directly in markup, KillerPDF's accent key is only known at runtime
|
||||||
|
// (SelectionAccent, resolved the same way SetFocusHalo resolves the card border). Without
|
||||||
|
// this they toggle Visible and still draw nothing: a transparent Border is invisible
|
||||||
|
// whatever its Visibility says.
|
||||||
|
if (TabEdgeLeft != null)
|
||||||
|
{
|
||||||
|
if (retro)
|
||||||
|
{
|
||||||
|
// This is the OUTER gray frame, not a duplicate highlight. The active first
|
||||||
|
// tab is inset one pixel: its own white bevel lands at x+1 and its inset light
|
||||||
|
// gray bevel at x+2, exactly where the pane draws those same two raised layers.
|
||||||
|
// Keeping the three responsibilities separate makes the complete side read
|
||||||
|
// gray / white / light-gray instead of a flat or doubled white line.
|
||||||
|
// Run through the band's final row so it meets the card edge below. Leaving the
|
||||||
|
// inactive case one pixel short exposed a literal gap in the left frame.
|
||||||
|
TabEdgeLeft.Margin = new Thickness(0, firstActive ? 3 : 5, 0, 0);
|
||||||
|
TabEdgeLeft.Visibility = firstActive || firstInactiveRetro
|
||||||
|
? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
TabEdgeLeft.SetResourceReference(Border.BackgroundProperty, "PaneBorderBrush");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
TabEdgeLeft.Margin = new Thickness(0, 9, 0, 0);
|
||||||
|
TabEdgeLeft.Visibility = lit && firstActive ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
TabEdgeLeft.SetResourceReference(Border.BackgroundProperty, "SelectionAccent");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (TabEdgeRight != null)
|
||||||
|
{
|
||||||
|
// The tab now reserves its final pixel, so this is only the outer frame. Because
|
||||||
|
// the border lives inside TabScroll it shares the tab's vertical origin and no
|
||||||
|
// longer starts above the tab or cuts through the scrollbar-arrow corner.
|
||||||
|
if (retro && lastInactiveRetro)
|
||||||
|
{
|
||||||
|
TabEdgeRight.Margin = new Thickness(0, 5, 0, 1);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
TabEdgeRight.Margin = retro ? new Thickness(0, 3, 0, 0) : new Thickness(0, 9, 0, 0);
|
||||||
|
}
|
||||||
|
TabEdgeRight.Visibility = retro
|
||||||
|
? (lastActive || lastInactiveRetro ? Visibility.Visible : Visibility.Collapsed)
|
||||||
|
: (lit && lastActive ? Visibility.Visible : Visibility.Collapsed);
|
||||||
|
TabEdgeRight.SetResourceReference(Border.BackgroundProperty, retro ? "PaneBorderBrush" : "SelectionAccent");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// TAB GESTURES
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Left-click switches on mouse-UP, so a press can begin a drag without switching first.
|
||||||
|
|
||||||
|
private void Tab_MouseDown(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not FrameworkElement fe || fe.DataContext is not DocumentSession s) return;
|
||||||
|
if (e.ChangedButton == MouseButton.Middle) { e.Handled = true; CloseTab(s); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Tab_RightClick(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not FrameworkElement fe || fe.DataContext is not DocumentSession s) return;
|
||||||
|
var menu = MakeThemedMenu();
|
||||||
|
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_CloseTab"), (_, _) => CloseTab(s), "Ctrl+W", ""));
|
||||||
|
var others = MakeMenuItem(Loc("Str_Ctx_CloseOthers"), (_, _) => CloseOtherTabs(s), "Ctrl+Shift+W", "");
|
||||||
|
others.IsEnabled = _sessions.Count(z => z.Doc != null || z.DeferredPath != null) > 1;
|
||||||
|
menu.Items.Add(others);
|
||||||
|
menu.PlacementTarget = fe;
|
||||||
|
menu.IsOpen = true;
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseTab_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is Button b && b.Tag is DocumentSession s) CloseTab(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// DRAG: reorder within this pane, or hand the tab to the other one
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Arm on press; past the threshold the grabbed tab glues to the cursor and its neighbors
|
||||||
|
// glide aside as it crosses their layout-slot midpoints. A plain click still switches on
|
||||||
|
// release.
|
||||||
|
//
|
||||||
|
// Over the OTHER pane the real tab cannot follow the hand - it is still parked in the strip it
|
||||||
|
// came from - so a ghost takes over and the reorder stands down (Shell/PaneDrag.cs). Coming
|
||||||
|
// back into this pane hands control straight back.
|
||||||
|
|
||||||
|
private DocumentSession? _tabDragSession;
|
||||||
|
private Point _tabDragStart;
|
||||||
|
private double _tabGrabDX;
|
||||||
|
private bool _tabDragging;
|
||||||
|
|
||||||
|
/// <summary>Cursor offset inside the grabbed tab, so the window's ghost can sit exactly where
|
||||||
|
/// the tab did when it was picked up.</summary>
|
||||||
|
internal double TabGrabOffsetX => _tabGrabDX;
|
||||||
|
|
||||||
|
private FrameworkElement? TabContainer(DocumentSession s)
|
||||||
|
=> TabStrip?.ItemContainerGenerator.ContainerFromItem(s) as FrameworkElement;
|
||||||
|
|
||||||
|
/// <summary>Did the press land on a button (the close x) rather than on the tab itself?</summary>
|
||||||
|
private static bool InsideButton(object src)
|
||||||
|
{
|
||||||
|
var d = src as System.Windows.DependencyObject;
|
||||||
|
while (d != null && d is not Button && d is not Window)
|
||||||
|
d = VisualTreeHelper.GetParent(d);
|
||||||
|
return d is Button;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Midpoint X of a tab's LAYOUT slot (ignores any in-flight slide transform).</summary>
|
||||||
|
private static double LayoutMidX(FrameworkElement fe)
|
||||||
|
{
|
||||||
|
var slot = System.Windows.Controls.Primitives.LayoutInformation.GetLayoutSlot(fe);
|
||||||
|
return slot.X + slot.Width / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Set a tab's horizontal offset immediately - glues the grabbed tab to the cursor.</summary>
|
||||||
|
private static void SetTabOffsetX(FrameworkElement tab, double x)
|
||||||
|
{
|
||||||
|
if (tab.RenderTransform is not TranslateTransform tt)
|
||||||
|
{
|
||||||
|
tt = new TranslateTransform();
|
||||||
|
tab.RenderTransform = tt;
|
||||||
|
}
|
||||||
|
tt.BeginAnimation(TranslateTransform.XProperty, null); // drop any prior animation so the set sticks
|
||||||
|
tt.X = x;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Glide a just-reordered neighbor from where it was into its new slot, so a swap
|
||||||
|
/// reads as a movement instead of an instant jump.</summary>
|
||||||
|
private static void AnimateTabSlide(FrameworkElement? tab, double fromX)
|
||||||
|
{
|
||||||
|
if (tab == null) return;
|
||||||
|
if (tab.RenderTransform is not TranslateTransform tt)
|
||||||
|
{
|
||||||
|
tt = new TranslateTransform();
|
||||||
|
tab.RenderTransform = tt;
|
||||||
|
}
|
||||||
|
tt.BeginAnimation(TranslateTransform.XProperty, null);
|
||||||
|
var anim = new System.Windows.Media.Animation.DoubleAnimation(fromX, 0,
|
||||||
|
new Duration(TimeSpan.FromMilliseconds(140)))
|
||||||
|
{
|
||||||
|
EasingFunction = new System.Windows.Media.Animation.CubicEase
|
||||||
|
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut },
|
||||||
|
};
|
||||||
|
tt.BeginAnimation(TranslateTransform.XProperty, anim);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void CleanupTabTransforms()
|
||||||
|
{
|
||||||
|
foreach (var s in _sessions)
|
||||||
|
if (TabContainer(s) is { } c)
|
||||||
|
{
|
||||||
|
c.RenderTransform = null;
|
||||||
|
Panel.SetZIndex(c, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Tab_DragDown(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not FrameworkElement bd || bd.DataContext is not DocumentSession s) return;
|
||||||
|
if (InsideButton(e.OriginalSource)) return; // the close x handles its own click
|
||||||
|
_tabDragSession = s;
|
||||||
|
_tabDragStart = e.GetPosition(TabStrip);
|
||||||
|
_tabGrabDX = e.GetPosition(bd).X;
|
||||||
|
_tabDragging = false;
|
||||||
|
bd.CaptureMouse();
|
||||||
|
// Own the press entirely so it cannot bubble to the title bar's window-drag handler, and
|
||||||
|
// so the mouse capture rather than the caption hit-test drives the drag - which is what
|
||||||
|
// makes it Y-independent.
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Tab_DragMove(object sender, MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not FrameworkElement bd || !bd.IsMouseCaptured || _tabDragSession is null) return;
|
||||||
|
var cont = TabContainer(_tabDragSession);
|
||||||
|
if (cont == null) return;
|
||||||
|
|
||||||
|
double x = e.GetPosition(TabStrip).X;
|
||||||
|
if (!_tabDragging && Math.Abs(x - _tabDragStart.X) < SystemParameters.MinimumHorizontalDragDistance) return;
|
||||||
|
_tabDragging = true;
|
||||||
|
Panel.SetZIndex(cont, 3); // the grabbed tab rides above its neighbors
|
||||||
|
|
||||||
|
var over = Host?.TabDropTarget(this, e);
|
||||||
|
Host?.UpdateTabDragFeedback(this, _tabDragSession, e, over);
|
||||||
|
if (over != null) return; // the ghost has it; no reorder while the pointer is away
|
||||||
|
|
||||||
|
int cur = _sessions.IndexOf(_tabDragSession);
|
||||||
|
double slide = cont.ActualWidth;
|
||||||
|
double rawLeft = x - _tabGrabDX;
|
||||||
|
double leftEdge = rawLeft;
|
||||||
|
double rightEdge = rawLeft + cont.ActualWidth;
|
||||||
|
double maxLeft = Math.Max(0, TabStrip.ActualWidth - slide);
|
||||||
|
double renderLeft = Math.Min(Math.Max(0, rawLeft), maxLeft);
|
||||||
|
|
||||||
|
// Swap when the ADVANCING edge crosses a neighbor's layout-slot midpoint. Edge against
|
||||||
|
// midpoint gives natural hysteresis, so a tab parked on a boundary does not bounce.
|
||||||
|
bool swapped = false;
|
||||||
|
if (cur + 1 < _sessions.Count && TabContainer(_sessions[cur + 1]) is { } right && rightEdge > LayoutMidX(right))
|
||||||
|
{
|
||||||
|
_sessions.Move(cur + 1, cur);
|
||||||
|
AnimateTabSlide(TabContainer(_sessions[cur]), slide); // it jumped left; glide it in from the right
|
||||||
|
swapped = true;
|
||||||
|
}
|
||||||
|
else if (cur - 1 >= 0 && TabContainer(_sessions[cur - 1]) is { } left && leftEdge < LayoutMidX(left))
|
||||||
|
{
|
||||||
|
_sessions.Move(cur - 1, cur);
|
||||||
|
AnimateTabSlide(TabContainer(_sessions[cur]), -slide); // it jumped right; glide it in from the left
|
||||||
|
swapped = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// After a swap the grabbed tab's slot has moved by a neighbor's width; refresh layout so
|
||||||
|
// the new slot is current, then offset it back under the cursor.
|
||||||
|
if (swapped) TabStrip.UpdateLayout();
|
||||||
|
var dragged = TabContainer(_tabDragSession);
|
||||||
|
if (dragged == null) return;
|
||||||
|
var slot = System.Windows.Controls.Primitives.LayoutInformation.GetLayoutSlot(dragged);
|
||||||
|
SetTabOffsetX(dragged, renderLeft - slot.X);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Tab_DragUp(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not FrameworkElement bd || !bd.IsMouseCaptured) return;
|
||||||
|
bd.ReleaseMouseCapture();
|
||||||
|
bool wasDragging = _tabDragging;
|
||||||
|
var s = _tabDragSession;
|
||||||
|
_tabDragSession = null;
|
||||||
|
_tabDragging = false;
|
||||||
|
Host?.HideTabDragFeedback(); // the ghost goes whatever the drop turns out to be
|
||||||
|
|
||||||
|
if (!wasDragging)
|
||||||
|
{
|
||||||
|
if (s != null) SwitchToTab(s);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dropped over the OTHER pane? Then this was a move, not a reorder. Checked on RELEASE
|
||||||
|
// rather than mid-drag on purpose: moving a tab between panes re-creates its container,
|
||||||
|
// which would pull the mouse capture out from under the drag that is still running.
|
||||||
|
if (s != null && Host?.TabDropTarget(this, e) is { } target)
|
||||||
|
{
|
||||||
|
Host.MoveTabToPane(this, target, s, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RebuildTabStrip(); // a reorder may have moved the active tab on or off an edge
|
||||||
|
|
||||||
|
// Settle the grabbed tab from its dragged offset into its final slot.
|
||||||
|
var cont = s != null ? TabContainer(s) : null;
|
||||||
|
if (cont?.RenderTransform is TranslateTransform tt && Math.Abs(tt.X) > 0.5)
|
||||||
|
{
|
||||||
|
var settle = new System.Windows.Media.Animation.DoubleAnimation(0,
|
||||||
|
new Duration(TimeSpan.FromMilliseconds(120)))
|
||||||
|
{
|
||||||
|
EasingFunction = new System.Windows.Media.Animation.CubicEase
|
||||||
|
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut },
|
||||||
|
};
|
||||||
|
settle.Completed += (_, _) => CleanupTabTransforms();
|
||||||
|
tt.BeginAnimation(TranslateTransform.XProperty, settle);
|
||||||
|
}
|
||||||
|
else CleanupTabTransforms();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// CROSS-PANE MOVE (the window drives this - Shell/PaneDrag.cs)
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// A session is already self-contained - it owns its document, annotations, undo stack, render
|
||||||
|
// cache and view state - so a move between panes is a move: out of one collection, into the
|
||||||
|
// other. Nothing is reloaded, which is what lets a large document cross without a re-render.
|
||||||
|
|
||||||
|
/// <summary>This pane's band, for the window's drop hit-test and caret math.</summary>
|
||||||
|
internal Border TabBandCtl => TabStripBorder;
|
||||||
|
|
||||||
|
/// <summary>This pane's strip, for the window's caret math.</summary>
|
||||||
|
internal ItemsControl TabStripCtl => TabStrip;
|
||||||
|
|
||||||
|
/// <summary>How many tabs this pane holds. The caret divides the band by this.</summary>
|
||||||
|
internal int TabCount => _sessions.Count;
|
||||||
|
|
||||||
|
/// <summary>Take <paramref name="s"/> out of this pane and pick whatever should be active in
|
||||||
|
/// its place. Pure bookkeeping - the caller re-renders, because which pane's fields are live
|
||||||
|
/// at that moment is its decision, not this one's.</summary>
|
||||||
|
internal void DetachSessionExt(DocumentSession s)
|
||||||
|
{
|
||||||
|
int idx = _sessions.IndexOf(s);
|
||||||
|
if (idx < 0) return;
|
||||||
|
|
||||||
|
_sessions.Remove(s);
|
||||||
|
// Its bitmaps travel with it: leaving the session in this pane's LRU would have this pane
|
||||||
|
// clearing a cache the other pane is now serving from.
|
||||||
|
_renderLru.Remove(s);
|
||||||
|
|
||||||
|
if (ReferenceEquals(_active, s))
|
||||||
|
SetActiveSession(_sessions.Count > 0 ? _sessions[Math.Min(idx, _sessions.Count - 1)] : null);
|
||||||
|
|
||||||
|
RebuildTabStrip();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Put <paramref name="s"/> into this pane at <paramref name="index"/> and make it
|
||||||
|
/// the front tab - the tab you just dragged is the one you are looking at.</summary>
|
||||||
|
internal void AdoptSessionExt(DocumentSession s, int index)
|
||||||
|
{
|
||||||
|
_sessions.Insert(Math.Min(Math.Max(0, index), _sessions.Count), s);
|
||||||
|
SetActiveSession(s);
|
||||||
|
RebuildTabStrip();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,865 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using PdfSharpCore.Pdf;
|
||||||
|
|
||||||
|
namespace KillerPDF.Controls
|
||||||
|
{
|
||||||
|
// Tabbed document support. KillerPDF keeps one window and one live "working set" of
|
||||||
|
// per-document fields (in MainWindow.xaml.cs). Each open PDF is a DocumentSession that
|
||||||
|
// owns its own copy of those fields. Switching tabs captures the live fields into the
|
||||||
|
// outgoing session and applies the incoming session's fields, then re-renders.
|
||||||
|
// Moved from Shell/Tabs.cs. THIS PANE's open documents and its own tab strip - `_sessions` and
|
||||||
|
// `_active` are per-pane, NOT one window-level list serving one window-level strip. That single
|
||||||
|
// ownership is what puts a document opened in the second pane's tab above the first one, and
|
||||||
|
// what makes the two panes fight over which shows a document; both symptoms are the same bug.
|
||||||
|
//
|
||||||
|
// DocumentSession deliberately carries VIEW state (zoom, page, scroll, view mode) alongside
|
||||||
|
// document state. That is correct because a session belongs to exactly one pane, so there is no
|
||||||
|
// second viewer to disagree with it. Opening the same file in both panes gives two independent
|
||||||
|
// copies, which is what makes that true - see the duplicate-file save guard, because two copies
|
||||||
|
// can otherwise save over each other.
|
||||||
|
//
|
||||||
|
// The tab STRIP - the band, the drag physics and the focus ring - lives in
|
||||||
|
// PdfViewer.TabStrip.cs. This file is the session model and its lifecycle.
|
||||||
|
public partial class PdfViewer
|
||||||
|
{
|
||||||
|
// One open document. Holds the per-document state that the rest of MainWindow reads
|
||||||
|
// and writes through its instance fields. The collection references here ARE the live
|
||||||
|
// collections while this session is active.
|
||||||
|
// internal, not private: PdfViewer.Bridge.cs types the active session as
|
||||||
|
// MainWindow.DocumentSession so the moved render pipeline can pass it to the render cache
|
||||||
|
// unchanged. Still nested, so it is only reachable as MainWindow.DocumentSession.
|
||||||
|
internal sealed class DocumentSession : System.ComponentModel.INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
public PdfDocument? Doc;
|
||||||
|
public string? CurrentFile;
|
||||||
|
public string? OriginalFile;
|
||||||
|
// Set on a restored tab that hasn't been loaded yet (lazy tabs): Doc stays null until the
|
||||||
|
// user first switches to it, so startup doesn't render every reopened PDF.
|
||||||
|
public string? DeferredPath;
|
||||||
|
|
||||||
|
public double ZoomLevel = 1.0;
|
||||||
|
public double LastRenderZoom = 1.0;
|
||||||
|
public FitMode Fit = FitMode.None;
|
||||||
|
public ViewMode View = ViewMode.Continuous;
|
||||||
|
public int GridColumns = 3; // grid column count; grid zoom is derived from this, so it must be per-tab too
|
||||||
|
public EditTool Tool = EditTool.Select; // active editing tool, remembered per document
|
||||||
|
public int PageIndex;
|
||||||
|
public bool IsDirty;
|
||||||
|
public bool ProtectedSource; // #149: source file had a password/encryption when opened
|
||||||
|
public double ScrollH;
|
||||||
|
public double ScrollV;
|
||||||
|
public int SearchPageCursor = -1;
|
||||||
|
|
||||||
|
public Dictionary<int, List<PageAnnotation>> Annotations = [];
|
||||||
|
public Dictionary<int, (int w, int h)> RenderDims = [];
|
||||||
|
// LRU render cache: rasterized page bitmaps keyed by (page, size-bucket, rotation). Lets a
|
||||||
|
// switch back to a recent tab reuse the bitmaps instead of re-running pdfium. Concurrent because
|
||||||
|
// the continuous/secondary streamers read it from a background thread. Cleared on edits that change
|
||||||
|
// a page's pixels or page order, and dropped entirely when the tab falls out of the LRU window.
|
||||||
|
public readonly System.Collections.Concurrent.ConcurrentDictionary<(int page, int bucket, int rot), System.Windows.Media.Imaging.BitmapSource> RenderCache = new();
|
||||||
|
// #189: each entry's byte size, recorded by the INSERTING thread. The budget must
|
||||||
|
// never be computed by reading PixelWidth/Height off the cached bitmaps - an entry
|
||||||
|
// that could not freeze throws cross-thread from whatever renderer is evicting,
|
||||||
|
// which killed the background render tasks (vanishing thumbnails after invert).
|
||||||
|
public readonly System.Collections.Concurrent.ConcurrentDictionary<(int page, int bucket, int rot), long> RenderCacheSize = new();
|
||||||
|
public Dictionary<int, int> PageRotations = [];
|
||||||
|
public Dictionary<int, string> FormTextValues = [];
|
||||||
|
public Dictionary<int, bool> FormCheckValues = [];
|
||||||
|
public Dictionary<string, string> FormRadioValues = [];
|
||||||
|
public Dictionary<int, double> FormFontSizes = [];
|
||||||
|
public Stack<UndoEntry> UndoStack = new();
|
||||||
|
public Stack<UndoEntry> RedoStack = new();
|
||||||
|
public Dictionary<int, List<(double left, double bottom, double right, double top)>> AllSearchRects = [];
|
||||||
|
public List<int> SearchResultPages = [];
|
||||||
|
|
||||||
|
public string Title =>
|
||||||
|
string.IsNullOrEmpty(OriginalFile)
|
||||||
|
? "Untitled"
|
||||||
|
: System.IO.Path.GetFileNameWithoutExtension(OriginalFile);
|
||||||
|
|
||||||
|
// ── Tab-strip presentation state ─────────────────────────────────────────────────
|
||||||
|
// Everything below is bound by the tab template (PdfViewer.xaml) and nothing else
|
||||||
|
// reads it. It has to NOTIFY: a strip row is only rebuilt when the collection itself
|
||||||
|
// changes, so a property edited in place on a live row would otherwise never repaint.
|
||||||
|
// (The same trap as KillerNotes issue #13.)
|
||||||
|
|
||||||
|
private string _tabLabel = "Untitled";
|
||||||
|
/// <summary>Title with the dirty dot, as the tab shows it.</summary>
|
||||||
|
public string TabLabel { get => _tabLabel; private set { if (_tabLabel != value) { _tabLabel = value; Notify(); } } }
|
||||||
|
|
||||||
|
private string _tabTip = "Untitled";
|
||||||
|
/// <summary>The tab's tooltip: the full path this document came from.</summary>
|
||||||
|
public string TabTip { get => _tabTip; private set { if (_tabTip != value) { _tabTip = value; Notify(); } } }
|
||||||
|
|
||||||
|
/// <summary>Re-read the label and tooltip off the document. Called from RebuildTabStrip,
|
||||||
|
/// which is the one funnel every add, close, save and load already goes through, so
|
||||||
|
/// there is no second place that has to remember to keep the strip current.</summary>
|
||||||
|
internal void RefreshTabLabel()
|
||||||
|
{
|
||||||
|
TabLabel = (IsDirty ? "• " : "") + Title;
|
||||||
|
TabTip = OriginalFile ?? "Untitled";
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _isActive;
|
||||||
|
/// <summary>The front tab of its pane.</summary>
|
||||||
|
public bool IsActive { get => _isActive; set { if (_isActive != value) { _isActive = value; Notify(); } } }
|
||||||
|
|
||||||
|
// Leftmost tab in the strip. Only the focus ring reads this: the band draws the ring's
|
||||||
|
// outermost verticals itself (TabEdgeLeft / TabEdgeRight), because a tab's own outer
|
||||||
|
// border sits on the ScrollViewer's clip edge and survives or vanishes depending on how
|
||||||
|
// the UniformGrid divided a fractional band width. Without it the first and last tab
|
||||||
|
// drew that side TOO, so the outer edge of the ring came out 2px wherever the clip
|
||||||
|
// spared it and 1px everywhere else.
|
||||||
|
private bool _isFirst;
|
||||||
|
public bool IsFirst { get => _isFirst; set { if (_isFirst != value) { _isFirst = value; Notify(); } } }
|
||||||
|
|
||||||
|
// Sitting on the strip's right EDGE - the last visible tab, but only while the overflow
|
||||||
|
// chevron is hidden. The tab's 1px right border is a divider BETWEEN tabs, so a tab on
|
||||||
|
// the edge drops it, where it would read as a stray rule; a tab with the chevron beside
|
||||||
|
// it still wants it. It also decides who owns the ring's right vertical.
|
||||||
|
private bool _isLast;
|
||||||
|
public bool IsLast { get => _isLast; set { if (_isLast != value) { _isLast = value; Notify(); } } }
|
||||||
|
|
||||||
|
// Win98 tabs overlap their immediate neighbor by one pixel, like the native tab
|
||||||
|
// control. These flags are only enabled by RebuildTabStrip while the retro theme is
|
||||||
|
// active, so the modern themes retain their existing edge-to-edge geometry.
|
||||||
|
private bool _retroBeforeActive;
|
||||||
|
public bool RetroBeforeActive { get => _retroBeforeActive; set { if (_retroBeforeActive != value) { _retroBeforeActive = value; Notify(); } } }
|
||||||
|
|
||||||
|
private bool _retroAfterActive;
|
||||||
|
public bool RetroAfterActive { get => _retroAfterActive; set { if (_retroAfterActive != value) { _retroAfterActive = value; Notify(); } } }
|
||||||
|
|
||||||
|
private bool _retroLastInactive;
|
||||||
|
public bool RetroLastInactive { get => _retroLastInactive; set { if (_retroLastInactive != value) { _retroLastInactive = value; Notify(); } } }
|
||||||
|
|
||||||
|
// Theme gate for chrome that must never leak into the shared tab template. Modern
|
||||||
|
// tabs keep their ShadowBar and normal canvas fills; 98SE replaces those with crisp
|
||||||
|
// pixel bevels and pane-focus shading.
|
||||||
|
private bool _useRetroTabChrome;
|
||||||
|
public bool UseRetroTabChrome { get => _useRetroTabChrome; set { if (_useRetroTabChrome != value) { _useRetroTabChrome = value; Notify(); } } }
|
||||||
|
|
||||||
|
// True only for the ACTIVE tab of the FOCUSED pane, and only while split. The focus ring
|
||||||
|
// has to continue around the active tab - the tab and the card are one surface, so a
|
||||||
|
// ring that stops at the strip reads as broken.
|
||||||
|
private bool _paneFocused;
|
||||||
|
public bool PaneFocused { get => _paneFocused; set { if (_paneFocused != value) { _paneFocused = value; Notify(); } } }
|
||||||
|
|
||||||
|
// Active tab of the pane that does NOT have focus. Not simply !PaneFocused: with one
|
||||||
|
// pane open there is no focused/unfocused distinction to draw, and the single pane's lip
|
||||||
|
// stays bright.
|
||||||
|
private bool _paneDimmed;
|
||||||
|
public bool PaneDimmed { get => _paneDimmed; set { if (_paneDimmed != value) { _paneDimmed = value; Notify(); } } }
|
||||||
|
|
||||||
|
// In the strip right now, as opposed to behind the chevron. The strip caps the NUMBER of
|
||||||
|
// tabs rather than letting them shrink without limit (ApplyTabWindow), and a tab outside
|
||||||
|
// the window collapses - UniformGrid ignores a collapsed child when it divides the band,
|
||||||
|
// so the ones left still fill it edge to edge. True by default: a tab is in the strip
|
||||||
|
// until something works out that it does not fit.
|
||||||
|
private bool _isStripVisible = true;
|
||||||
|
public bool IsStripVisible { get => _isStripVisible; set { if (_isStripVisible != value) { _isStripVisible = value; Notify(); } } }
|
||||||
|
|
||||||
|
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
private void Notify([System.Runtime.CompilerServices.CallerMemberName] string? name = null)
|
||||||
|
=> PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObservableCollection, not List: the strip is an ItemsControl bound straight to this, so an
|
||||||
|
// add, a close or a drag-reorder repaints on its own. That binding is the whole point of the
|
||||||
|
// port - the strip used to be code-built Borders kept in step by hand.
|
||||||
|
private readonly System.Collections.ObjectModel.ObservableCollection<DocumentSession> _sessions = [];
|
||||||
|
private DocumentSession? _active;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Session state capture / apply
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Copy the live working set INTO the session (call before switching away from it).
|
||||||
|
private void CaptureSessionState(DocumentSession s)
|
||||||
|
{
|
||||||
|
s.Doc = _doc;
|
||||||
|
s.CurrentFile = _currentFile;
|
||||||
|
s.OriginalFile = _originalFile;
|
||||||
|
s.ZoomLevel = _zoomLevel;
|
||||||
|
s.LastRenderZoom = _lastRenderZoom;
|
||||||
|
s.Fit = _fitMode;
|
||||||
|
s.View = _viewMode;
|
||||||
|
s.GridColumns = _gridColumns;
|
||||||
|
s.Tool = _currentTool;
|
||||||
|
s.IsDirty = _isDirty;
|
||||||
|
s.ProtectedSource = _openedFromProtected;
|
||||||
|
s.SearchPageCursor = Search.PageCursor;
|
||||||
|
// State.CurrentPage, not PageList.SelectedIndex: the sidebar is a window singleton
|
||||||
|
// that follows the FOCUSED pane, so an unfocused pane capturing (the close path, the
|
||||||
|
// save path's dirty check) was parking the OTHER pane's page number into its session.
|
||||||
|
// Identical for the focused pane by the stage-3a sync.
|
||||||
|
s.PageIndex = State.CurrentPage >= 0 ? State.CurrentPage : s.PageIndex;
|
||||||
|
s.ScrollH = PagePreviewPanel?.HorizontalOffset ?? 0;
|
||||||
|
s.ScrollV = PagePreviewPanel?.VerticalOffset ?? 0;
|
||||||
|
|
||||||
|
s.Annotations = _annotations;
|
||||||
|
s.RenderDims = _renderDims;
|
||||||
|
s.PageRotations = _pageRotations;
|
||||||
|
s.FormTextValues = _formTextValues;
|
||||||
|
s.FormCheckValues = _formCheckValues;
|
||||||
|
s.FormRadioValues = _formRadioValues;
|
||||||
|
s.FormFontSizes = _formFontSizes;
|
||||||
|
s.UndoStack = _undoStack;
|
||||||
|
s.RedoStack = _redoStack;
|
||||||
|
s.AllSearchRects = Search.AllSearchRects;
|
||||||
|
s.SearchResultPages = Search.ResultPages;
|
||||||
|
// Persist this document's fit/zoom/view/page so reopening it (even after a restart) restores it.
|
||||||
|
// Two-pane guard: DocStates is keyed by file path, so the SAME file open in BOTH panes
|
||||||
|
// (two independent copies) is two writers on one entry - whichever pane captured last
|
||||||
|
// silently overwrote the state the user actually left the file in, and on quit that was
|
||||||
|
// just the close path's fixed A-then-B capture order. Only the focused pane writes when
|
||||||
|
// the other pane also holds the file. FocusPane captures the outgoing pane BEFORE the
|
||||||
|
// swap, so the pane being LEFT still counts as focused here - the rule this yields is
|
||||||
|
// "the most recently used pane wins". A pane holding the only copy always writes.
|
||||||
|
if (Host == null || Host.IsViewerFocused(this) || !Host.OtherViewerHasFile(this, s.OriginalFile))
|
||||||
|
SaveDocState(s.OriginalFile, s.Fit, s.ZoomLevel, s.View, s.PageIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-document view state (persisted across restarts, keyed by file path) ──────────────────
|
||||||
|
// So reopening a file restores how you left it (fit mode, zoom, view mode, page) instead of the
|
||||||
|
// per-view-mode default. Stored as one registry value: lines of "path|fit|zoom|view|page", most
|
||||||
|
// recent first, capped. '|' and newline are both illegal in Windows paths, so they're safe delimiters.
|
||||||
|
private const int DocStatesMax = 40;
|
||||||
|
|
||||||
|
private void SaveDocState(string? path, FitMode fit, double zoom, ViewMode view, int page)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return; // skip Untitled/imported
|
||||||
|
string entry = string.Join("|", path,
|
||||||
|
fit.ToString(),
|
||||||
|
zoom.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
view.ToString(),
|
||||||
|
page.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
var lines = new List<string> { entry };
|
||||||
|
var raw = App.GetSetting("DocStates");
|
||||||
|
if (!string.IsNullOrEmpty(raw))
|
||||||
|
foreach (var line in raw!.Split('\n'))
|
||||||
|
{
|
||||||
|
if (line.Length == 0) continue;
|
||||||
|
int bar = line.IndexOf('|');
|
||||||
|
string lpath = bar > 0 ? line[..bar] : line;
|
||||||
|
if (!string.Equals(lpath, path, StringComparison.OrdinalIgnoreCase))
|
||||||
|
lines.Add(line);
|
||||||
|
}
|
||||||
|
if (lines.Count > DocStatesMax) lines = lines.GetRange(0, DocStatesMax);
|
||||||
|
App.SetSetting("DocStates", string.Join("\n", lines));
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetDocState(string? path, out FitMode fit, out double zoom, out ViewMode view, out int page)
|
||||||
|
{
|
||||||
|
fit = FitMode.None; zoom = 1.0; view = ViewMode.Continuous; page = 0;
|
||||||
|
if (string.IsNullOrEmpty(path)) return false;
|
||||||
|
var raw = App.GetSetting("DocStates");
|
||||||
|
if (string.IsNullOrEmpty(raw)) return false;
|
||||||
|
foreach (var line in raw!.Split('\n'))
|
||||||
|
{
|
||||||
|
var p = line.Split('|');
|
||||||
|
if (p.Length < 5 || !string.Equals(p[0], path, StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
Enum.TryParse(p[1], out fit);
|
||||||
|
double.TryParse(p[2], System.Globalization.NumberStyles.Float,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture, out zoom);
|
||||||
|
Enum.TryParse(p[3], out view);
|
||||||
|
int.TryParse(p[4], out page);
|
||||||
|
if (zoom <= 0) zoom = 1.0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point the live working set AT the session's state. Pure field assignment - no UI.
|
||||||
|
private void ApplySessionState(DocumentSession s)
|
||||||
|
{
|
||||||
|
_doc = s.Doc;
|
||||||
|
_currentFile = s.CurrentFile;
|
||||||
|
_originalFile = s.OriginalFile;
|
||||||
|
// The cached PDFium link handle belongs to the file we're switching AWAY from. This is the one
|
||||||
|
// chokepoint every active-doc swap funnels through (tab switch, close-tab, close-all), so drop
|
||||||
|
// it here and it can never outlive its document; the next link extraction reopens it lazily for
|
||||||
|
// the new file (see EnsureLinkPdfiumDoc). CloseLinkPdfiumDoc is idempotent and cheap.
|
||||||
|
CloseLinkPdfiumDoc();
|
||||||
|
_zoomLevel = s.ZoomLevel;
|
||||||
|
_lastRenderZoom = s.LastRenderZoom;
|
||||||
|
_fitMode = s.Fit;
|
||||||
|
_viewMode = s.View;
|
||||||
|
_gridColumns = s.GridColumns;
|
||||||
|
_currentTool = s.Tool;
|
||||||
|
_isDirty = s.IsDirty;
|
||||||
|
_openedFromProtected = s.ProtectedSource;
|
||||||
|
Search.PageCursor = s.SearchPageCursor;
|
||||||
|
|
||||||
|
_annotations = s.Annotations;
|
||||||
|
_renderDims = s.RenderDims;
|
||||||
|
_pageRotations = s.PageRotations;
|
||||||
|
_formTextValues = s.FormTextValues;
|
||||||
|
_formCheckValues = s.FormCheckValues;
|
||||||
|
_formRadioValues = s.FormRadioValues;
|
||||||
|
_formFontSizes = s.FormFontSizes;
|
||||||
|
_undoStack = s.UndoStack;
|
||||||
|
_redoStack = s.RedoStack;
|
||||||
|
_navBack.Clear(); // jump history is per-view-session: a tab switch starts fresh
|
||||||
|
_navForward.Clear();
|
||||||
|
Search.AllSearchRects = s.AllSearchRects;
|
||||||
|
Search.ResultPages = s.SearchResultPages;
|
||||||
|
TouchRenderLru(s); // this tab is now active: keep its render cache, evict tabs beyond the window
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LRU render-bitmap cache ───────────────────────────────────────────────────────────────────
|
||||||
|
// Keeps the rasterized page bitmaps of the most-recent few tabs so switching back skips pdfium and
|
||||||
|
// fills instantly. The render paths (single / secondary tiles / continuous) check the active tab's
|
||||||
|
// cache before rasterizing and store the frozen bitmap after building it.
|
||||||
|
private readonly List<DocumentSession> _renderLru = [];
|
||||||
|
private const int RenderCacheTabCap = 3;
|
||||||
|
|
||||||
|
// Background-thread safe: a cached frozen bitmap for this render, or null (the caller must rasterize).
|
||||||
|
internal static System.Windows.Media.Imaging.BitmapSource? TryGetCachedRender(DocumentSession? s, int page, int bucket, int rot)
|
||||||
|
=> (s != null && s.RenderCache.TryGetValue((page, bucket, rot), out var b)) ? b : null;
|
||||||
|
|
||||||
|
// #122: cap the number of cached page bitmaps per tab. The cache used to grow without
|
||||||
|
// bound (one bitmap per page ever rendered, several MB each), so scrolling a large
|
||||||
|
// image-heavy document in Continuous view pinned gigabytes in one tab.
|
||||||
|
private const int RenderCachePageCap = 48;
|
||||||
|
|
||||||
|
// #189: the count cap alone was not enough - an entry's size scales with the page and the
|
||||||
|
// base render budget, so 48 cached Letter pages held ~630 MB in one tab. Budget the cache
|
||||||
|
// in BYTES too, with a floor of nearby pages so the moving window around the viewport
|
||||||
|
// still serves instantly. Frozen BitmapSources are safe to measure from any thread.
|
||||||
|
private const long RenderCacheByteBudget = 160L << 20; // ~160 MB per tab
|
||||||
|
private const int RenderCacheMinPages = 6;
|
||||||
|
|
||||||
|
private static long RenderCacheBytes(DocumentSession s)
|
||||||
|
{
|
||||||
|
long total = 0;
|
||||||
|
foreach (var size in s.RenderCacheSize.Values) total += size;
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void CacheRender(DocumentSession? s, int page, int bucket, int rot, System.Windows.Media.Imaging.BitmapSource bmp)
|
||||||
|
{
|
||||||
|
if (s == null) return;
|
||||||
|
if (bmp.CanFreeze && !bmp.IsFrozen) bmp.Freeze();
|
||||||
|
// Measured HERE, on the thread that made the bitmap - see RenderCacheSize.
|
||||||
|
s.RenderCacheSize[(page, bucket, rot)] = 4L * bmp.PixelWidth * bmp.PixelHeight;
|
||||||
|
s.RenderCache[(page, bucket, rot)] = bmp;
|
||||||
|
// Evict the entries farthest from the page just cached: renders arrive around the
|
||||||
|
// viewport, so this keeps a moving window of nearby pages hot and stays safe to run
|
||||||
|
// from any thread (no UI state needed).
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
int count = s.RenderCache.Count;
|
||||||
|
bool overCount = count > RenderCachePageCap;
|
||||||
|
bool overBytes = count > RenderCacheMinPages && RenderCacheBytes(s) > RenderCacheByteBudget;
|
||||||
|
if (!overCount && !overBytes) break;
|
||||||
|
var farthest = default((int page, int bucket, int rot));
|
||||||
|
int bestDist = -1;
|
||||||
|
foreach (var key in s.RenderCache.Keys)
|
||||||
|
{
|
||||||
|
int d = Math.Abs(key.page - page);
|
||||||
|
if (d > bestDist) { bestDist = d; farthest = key; }
|
||||||
|
}
|
||||||
|
if (bestDist <= 0) break; // only current-page entries left; nothing sane to evict
|
||||||
|
s.RenderCache.TryRemove(farthest, out _);
|
||||||
|
s.RenderCacheSize.TryRemove(farthest, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #135: the invert state is baked into cached pixels - drop every cached tab's page
|
||||||
|
// bitmaps when it flips so no stale-colored bitmap survives the toggle. The image-rect
|
||||||
|
// cache (the carve-out that keeps pictures uninverted) goes with it; it re-fills lazily.
|
||||||
|
private void FlushAllRenderCaches()
|
||||||
|
{
|
||||||
|
foreach (var s in _renderLru) { s.RenderCache.Clear(); s.RenderCacheSize.Clear(); }
|
||||||
|
// THIS pane's rect cache - the bare call, NOT `Viewer.FlushImageRectCache()`, which
|
||||||
|
// hardcodes pane A and leaves pane B's night-mode carve-out cache serving rects from
|
||||||
|
// the previous state after an invert toggle.
|
||||||
|
FlushImageRectCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark a tab most-recently-used; drop the bitmap caches of tabs that fall outside the LRU window.
|
||||||
|
private void TouchRenderLru(DocumentSession? s)
|
||||||
|
{
|
||||||
|
if (s == null) return;
|
||||||
|
_renderLru.Remove(s);
|
||||||
|
_renderLru.Add(s);
|
||||||
|
bool dropped = false;
|
||||||
|
while (_renderLru.Count > RenderCacheTabCap)
|
||||||
|
{
|
||||||
|
var old = _renderLru[0];
|
||||||
|
_renderLru.RemoveAt(0);
|
||||||
|
old.RenderCache.Clear();
|
||||||
|
old.RenderCacheSize.Clear();
|
||||||
|
dropped = true;
|
||||||
|
}
|
||||||
|
if (dropped) CompactLohSoon();
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: .NET Framework never compacts the Large Object Heap on its own, so even after the
|
||||||
|
// page-bitmap caches are dropped the process keeps its peak RAM (the classic "closed the
|
||||||
|
// tab, Task Manager still shows gigabytes"). Request a one-shot LOH compaction at idle,
|
||||||
|
// deferred so it never janks the close/switch animation itself.
|
||||||
|
private void CompactLohSoon()
|
||||||
|
{
|
||||||
|
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.ApplicationIdle, (Action)(() =>
|
||||||
|
{
|
||||||
|
System.Runtime.GCSettings.LargeObjectHeapCompactionMode =
|
||||||
|
System.Runtime.GCLargeObjectHeapCompactionMode.CompactOnce;
|
||||||
|
GC.Collect();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop a tab's cached bitmaps after an edit that changes page pixels or page order.
|
||||||
|
private void InvalidateRenderCache(DocumentSession? s)
|
||||||
|
{
|
||||||
|
s?.RenderCache.Clear();
|
||||||
|
s?.RenderCacheSize.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure there is always at least one session, adopting whatever is currently live.
|
||||||
|
private void EnsureInitialSession()
|
||||||
|
{
|
||||||
|
if (_sessions.Count > 0) return;
|
||||||
|
var s = new DocumentSession();
|
||||||
|
_sessions.Add(s);
|
||||||
|
_active = s;
|
||||||
|
// Only adopt the live working set when this pane actually owns it. CaptureSessionState
|
||||||
|
// copies _doc, _annotations, _undoStack and the rest BY REFERENCE, so capturing while
|
||||||
|
// the shared fields still describe the other pane makes this session an alias of that
|
||||||
|
// pane's live document - the same trap ApplyActiveSessionIfAny guards against. An
|
||||||
|
// unfocused pane's first session stays genuinely blank instead.
|
||||||
|
if (Host == null || Host.IsViewerFocused(this)) CaptureSessionState(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit / cancel any in-progress interaction so it doesn't bleed onto another document.
|
||||||
|
private void CancelTransientForSwitch()
|
||||||
|
{
|
||||||
|
CommitActiveTextBox();
|
||||||
|
RemoveTextEditHandles();
|
||||||
|
ClearSelection();
|
||||||
|
ClearTextSelection();
|
||||||
|
CloseSearchBar();
|
||||||
|
HideDrawSettings();
|
||||||
|
HideTextSettings();
|
||||||
|
HideSignaturePopup();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Rendering the active session
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Re-render whatever document the active session holds (or show the empty drop zone).
|
||||||
|
// The shared page context menu, attached per pane by MainWindow.BuildContextMenu. The
|
||||||
|
// opening hook rebuilds items at the cursor; per-tile overlays populate programmatically
|
||||||
|
// (which does not raise ContextMenuOpening), same contract as before.
|
||||||
|
internal void AttachContextMenuExt(System.Windows.Controls.ContextMenu menu)
|
||||||
|
{
|
||||||
|
_annotationCanvas.ContextMenu = menu;
|
||||||
|
_annotationCanvas.ContextMenuOpening += (s, e) =>
|
||||||
|
PopulateContextMenu(System.Windows.Input.Mouse.GetPosition(_annotationCanvas),
|
||||||
|
Math.Max(0, _currentPage));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-pane cache flush for the per-pane invert toggle: only THIS pane's sessions hold
|
||||||
|
// stale pixels, and flushing every pane's cache made the untouched pane visibly
|
||||||
|
// re-render on the other pane's toggle (2026-08-15).
|
||||||
|
internal void FlushOwnRenderCaches()
|
||||||
|
{
|
||||||
|
foreach (var s in _sessions) { s.RenderCache.Clear(); s.RenderCacheSize.Clear(); }
|
||||||
|
FlushImageRectCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invert repaint for an UNFOCUSED pane: PIXELS ONLY. RenderActiveSession here ran
|
||||||
|
// BootstrapDocumentView / ShowEmptyState, whose Host chrome mutations (sidebar rebuild
|
||||||
|
// with this pane's thumbnails - or ClearSidebarPages and control-disabling when this
|
||||||
|
// pane is EMPTY) trashed the focused pane's shared sidebar (2026-08-15). An empty pane
|
||||||
|
// has no pixels to repaint and must cause no side effects at all.
|
||||||
|
internal void RepaintPixelsExt()
|
||||||
|
{
|
||||||
|
if (_doc is null) return;
|
||||||
|
if (_viewMode == ViewMode.Continuous)
|
||||||
|
{
|
||||||
|
_continuousSharpenCts?.Cancel();
|
||||||
|
_continuousSharpPages.Clear();
|
||||||
|
foreach (var child in _continuousPanel.Children)
|
||||||
|
if (child is Border b && b.Child is Grid g
|
||||||
|
&& g.Children.Count > 0 && g.Children[0] is System.Windows.Controls.Image img)
|
||||||
|
img.Source = null;
|
||||||
|
_ = RenderContinuousPages(Math.Max(0, _currentPage));
|
||||||
|
StartRerenderTimer();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RenderPage(_viewMode == ViewMode.Grid ? 0 : Math.Max(0, _currentPage), keepTiles: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderActiveSession()
|
||||||
|
{
|
||||||
|
if (_active == null || _active.Doc == null) { ShowEmptyState(); return; }
|
||||||
|
|
||||||
|
FileNameLabel.Text = System.IO.Path.GetFileName(_active.OriginalFile ?? "");
|
||||||
|
_annotationCanvas.Children.Clear();
|
||||||
|
MarkDirty(_isDirty); // sync the Save button color to this tab's dirty state
|
||||||
|
BootstrapDocumentView(_active.PageIndex, autoFit: false);
|
||||||
|
SetTool(_active.Tool); // restore this document's active editing tool (and its tool bar)
|
||||||
|
|
||||||
|
// Restore the saved scroll position after the Background zoom pass queued inside
|
||||||
|
// BootstrapDocumentView has run (ContextIdle is lower priority than Background).
|
||||||
|
double sh = _active.ScrollH, sv = _active.ScrollV;
|
||||||
|
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.ContextIdle, (Action)(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PagePreviewPanel.ScrollToHorizontalOffset(sh);
|
||||||
|
PagePreviewPanel.ScrollToVerticalOffset(sv);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visual reset to the no-document drop-zone state. Mirrors CloseFile's teardown but
|
||||||
|
// does not close the document or touch session bookkeeping (callers handle that).
|
||||||
|
private void ShowEmptyState()
|
||||||
|
{
|
||||||
|
_activeTextBox = null;
|
||||||
|
RemoveTextEditHandles();
|
||||||
|
_thumbCts?.Cancel();
|
||||||
|
Host?.ClearSidebarPages(this);
|
||||||
|
PageImage.Source = null;
|
||||||
|
_annotationCanvas.Children.Clear();
|
||||||
|
FileNameLabel.Text = "";
|
||||||
|
DropZone.Visibility = Visibility.Visible;
|
||||||
|
PopulateRecentFilesList();
|
||||||
|
PagePreviewPanel.Visibility = Visibility.Collapsed;
|
||||||
|
CloseSearchBar();
|
||||||
|
HideDrawSettings();
|
||||||
|
HideTextSettings();
|
||||||
|
HideSignaturePopup();
|
||||||
|
SetTool(EditTool.Select);
|
||||||
|
if (Host != null)
|
||||||
|
{
|
||||||
|
Host.CloseFileEnabled = false;
|
||||||
|
Host.PageJumpEnabled = false;
|
||||||
|
}
|
||||||
|
_continuousRenderCts?.Cancel();
|
||||||
|
_continuousPanel.Children.Clear();
|
||||||
|
_continuousTops.Clear();
|
||||||
|
if (Host != null)
|
||||||
|
{
|
||||||
|
Host.PageJumpText = "";
|
||||||
|
Host.PageTotalText = "/ -";
|
||||||
|
}
|
||||||
|
OutlineTree.Items.Clear();
|
||||||
|
SidebarOutlinesTab.IsEnabled = false;
|
||||||
|
if (_sidebarShowingOutlines) SwitchSidebarToPagesTab();
|
||||||
|
SyncSidebarToDocState(hasDoc: false, startup: false); // nothing open: collapse the rail, hide page controls
|
||||||
|
MarkDirty(false);
|
||||||
|
SetStatus(Loc("Str_Ready"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Opening / switching / closing tabs
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Prepare a tab to receive a document load: capture the current tab, then either reuse
|
||||||
|
// the active tab if it's empty or create a new one, and blank the live working set.
|
||||||
|
private DocumentSession BeginTabLoad(out DocumentSession? prev, out bool createdNew)
|
||||||
|
{
|
||||||
|
EnsureInitialSession();
|
||||||
|
CommitActiveTextBox();
|
||||||
|
CancelTransientForSwitch();
|
||||||
|
prev = _active;
|
||||||
|
if (_active != null) CaptureSessionState(_active);
|
||||||
|
|
||||||
|
DocumentSession target;
|
||||||
|
if (_active != null && _active.Doc == null && _active.DeferredPath == null)
|
||||||
|
{
|
||||||
|
target = _active; // reuse the current empty tab (never a deferred one)
|
||||||
|
createdNew = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
target = new DocumentSession();
|
||||||
|
// Inherit the current view mode so a newly opened PDF doesn't snap back to the
|
||||||
|
// default (Continuous) when the user prefers Single / Two-Page / Grid.
|
||||||
|
if (prev != null) { target.View = prev.View; target.Fit = prev.Fit; }
|
||||||
|
_sessions.Add(target);
|
||||||
|
createdNew = true;
|
||||||
|
}
|
||||||
|
SetActiveSession(target);
|
||||||
|
ApplySessionState(target); // blank live fields (target has no document yet)
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roll back a failed / canceled load started by BeginTabLoad.
|
||||||
|
private void AbortTabLoad(DocumentSession target, DocumentSession? prev, bool createdNew)
|
||||||
|
{
|
||||||
|
if (createdNew) _sessions.Remove(target);
|
||||||
|
SetActiveSession(prev);
|
||||||
|
if (prev != null) { ApplySessionState(prev); RenderActiveSession(); }
|
||||||
|
else { EnsureInitialSession(); RenderActiveSession(); }
|
||||||
|
RebuildTabStrip();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns an open session for the given file path (case-insensitive full-path match), or null.
|
||||||
|
private DocumentSession? FindOpenSession(string path)
|
||||||
|
{
|
||||||
|
string full;
|
||||||
|
try { full = System.IO.Path.GetFullPath(path); } catch { full = path; }
|
||||||
|
return _sessions.FirstOrDefault(s =>
|
||||||
|
(s.Doc != null || s.DeferredPath != null) &&
|
||||||
|
!string.IsNullOrEmpty(s.OriginalFile) &&
|
||||||
|
string.Equals(SafeFullPath(s.OriginalFile!), full, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SafeFullPath(string p)
|
||||||
|
{
|
||||||
|
try { return System.IO.Path.GetFullPath(p); } catch { return p; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open a PDF in its own tab (reusing the current tab if it is empty). If the same file is
|
||||||
|
// already open in an unedited tab, switch to that tab instead of opening a duplicate.
|
||||||
|
private void OpenInNewTab(string path)
|
||||||
|
{
|
||||||
|
EnsureInitialSession();
|
||||||
|
CommitActiveTextBox();
|
||||||
|
if (_active != null) CaptureSessionState(_active); // keep dirty / path current for the check
|
||||||
|
|
||||||
|
var existing = FindOpenSession(path);
|
||||||
|
if (existing != null && !existing.IsDirty)
|
||||||
|
{
|
||||||
|
SwitchToTab(existing);
|
||||||
|
SetStatus(string.Format(Loc("Str_St_AlreadyOpen"), System.IO.Path.GetFileName(path)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = BeginTabLoad(out var prev, out bool createdNew);
|
||||||
|
OpenFile(path);
|
||||||
|
if (_doc == null)
|
||||||
|
{
|
||||||
|
// A background open (encryption strip / repair) finalizes this tab itself, so the
|
||||||
|
// not-yet-loaded _doc isn't a failure - leave the tab in place.
|
||||||
|
if (_asyncOpenPending) return;
|
||||||
|
// Open failed, was canceled, or a password prompt was dismissed.
|
||||||
|
AbortTabLoad(target, prev, createdNew);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CaptureSessionState(_active!);
|
||||||
|
SetTool(_currentTool); // sync the tool UI to this (new) tab's tool
|
||||||
|
RebuildTabStrip();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cycle to the next (dir = +1) or previous (dir = -1) open document tab.
|
||||||
|
private void CycleTab(int dir)
|
||||||
|
{
|
||||||
|
var docTabs = _sessions.Where(t => t.Doc != null || t.DeferredPath != null).ToList();
|
||||||
|
if (docTabs.Count < 2 || _active == null) return;
|
||||||
|
int i = docTabs.IndexOf(_active);
|
||||||
|
if (i < 0) return;
|
||||||
|
int next = (i + dir + docTabs.Count) % docTabs.Count;
|
||||||
|
SwitchToTab(docTabs[next]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Make <paramref name="s"/> this pane's active session and mark its tab.
|
||||||
|
///
|
||||||
|
/// The IsActive flags are what the strip template triggers on, so every write to _active
|
||||||
|
/// goes through here - a raw assignment leaves the old tab drawn as the front one.</summary>
|
||||||
|
private void SetActiveSession(DocumentSession? s)
|
||||||
|
{
|
||||||
|
_active = s;
|
||||||
|
foreach (var t in _sessions) t.IsActive = ReferenceEquals(t, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch the active tab to an already-loaded session.
|
||||||
|
private void SwitchToTab(DocumentSession target)
|
||||||
|
{
|
||||||
|
if (target == _active) return;
|
||||||
|
// _doc, _annotations and the rest of the "live working set" are window fields shared by
|
||||||
|
// BOTH panes (see PdfViewer.Bridge.cs) - they describe whichever pane is ActiveViewer,
|
||||||
|
// not this pane specifically. Clicking a tab in a pane that is not (yet) focused must
|
||||||
|
// claim that ownership FIRST, or CaptureSessionState/ApplySessionState below read and
|
||||||
|
// write the OTHER pane's fields: this pane's tab strip ends up showing the right tab
|
||||||
|
// while its canvas gets repainted with whatever the actually-focused pane rendered next.
|
||||||
|
// FocusPane no-ops when this pane already owns focus. (#161 - "clicking a tab on one
|
||||||
|
// pane is making it show up in the other one again", 2026-08-01.)
|
||||||
|
Host?.FocusViewer(this);
|
||||||
|
CommitActiveTextBox();
|
||||||
|
CancelTransientForSwitch();
|
||||||
|
if (_active != null) CaptureSessionState(_active);
|
||||||
|
SetActiveSession(target);
|
||||||
|
ApplySessionState(target);
|
||||||
|
// Hide the document content while the new tab renders and restores its scroll position, then fade
|
||||||
|
// it in. This masks the rebuild and the "loads at the top then snaps to my place" jump - the user
|
||||||
|
// only sees the final, correctly-scrolled view fade in. PageContentGrid is the parent of BOTH the
|
||||||
|
// single/grid panel and the continuous panel, so one fade covers every view mode.
|
||||||
|
PageContentGrid.BeginAnimation(UIElement.OpacityProperty, null);
|
||||||
|
PageContentGrid.Opacity = 0;
|
||||||
|
if (target.Doc == null && target.DeferredPath != null)
|
||||||
|
MaterializeDeferred(target);
|
||||||
|
else
|
||||||
|
RenderActiveSession();
|
||||||
|
// The switch can move the overflow window (the incoming tab may have been behind the
|
||||||
|
// chevron), which changes which tab sits on each edge - and that is the card's corner
|
||||||
|
// rounding and the ring's outer verticals, not just the strip.
|
||||||
|
RebuildTabStrip();
|
||||||
|
FadeInDocContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade the document pane content back in after a switch. Queued at ContextIdle so it runs AFTER the
|
||||||
|
// scroll-position restore (also ContextIdle, queued earlier by RenderActiveSession) - the snap to
|
||||||
|
// position happens while hidden, so it's never seen. Always lands at full opacity.
|
||||||
|
private void FadeInDocContent()
|
||||||
|
{
|
||||||
|
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.ContextIdle, (Action)(() =>
|
||||||
|
{
|
||||||
|
var fade = new System.Windows.Media.Animation.DoubleAnimation(
|
||||||
|
0, 1, new Duration(TimeSpan.FromMilliseconds(140)))
|
||||||
|
{ EasingFunction = new System.Windows.Media.Animation.QuadraticEase { EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut } };
|
||||||
|
fade.Completed += (_, _) =>
|
||||||
|
{
|
||||||
|
PageContentGrid.BeginAnimation(UIElement.OpacityProperty, null);
|
||||||
|
PageContentGrid.Opacity = 1;
|
||||||
|
};
|
||||||
|
PageContentGrid.BeginAnimation(UIElement.OpacityProperty, fade);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load a restored-but-deferred tab's PDF the first time it is viewed (lazy tabs). The session
|
||||||
|
// must already be the live working set (ApplySessionState called) before this runs.
|
||||||
|
private void MaterializeDeferred(DocumentSession target)
|
||||||
|
{
|
||||||
|
var path = target.DeferredPath;
|
||||||
|
target.DeferredPath = null;
|
||||||
|
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path))
|
||||||
|
{
|
||||||
|
RenderActiveSession(); // file vanished since last session - show the empty state
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
OpenFile(path!); // loads into the live fields and renders the view
|
||||||
|
if (_doc == null)
|
||||||
|
{
|
||||||
|
if (_asyncOpenPending) return; // background strip/repair finalizes the tab itself
|
||||||
|
RenderActiveSession(); return;
|
||||||
|
}
|
||||||
|
CaptureSessionState(target); // persist the now-loaded document back into the session
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close a tab. Prompts to save if that tab has unsaved changes, then switches to a
|
||||||
|
// neighboring tab (or the empty state when the last tab closes).
|
||||||
|
// Closes every open document tab except `keep` (each may prompt to save if dirty, like a manual close).
|
||||||
|
private void CloseOtherTabs(DocumentSession keep)
|
||||||
|
{
|
||||||
|
foreach (var s in _sessions.Where(z => !ReferenceEquals(z, keep) && (z.Doc != null || z.DeferredPath != null)).ToList())
|
||||||
|
CloseTab(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseTab(DocumentSession? s)
|
||||||
|
{
|
||||||
|
EnsureInitialSession();
|
||||||
|
if (s == null) return;
|
||||||
|
|
||||||
|
// Same reason as the top of SwitchToTab: claim the shared fields for this pane before
|
||||||
|
// touching them below, in case this pane is not (yet) ActiveViewer - e.g. the tab
|
||||||
|
// context menu's Close Tab / Close Other Tabs, invoked directly on this pane's own
|
||||||
|
// instance. No-ops when already focused.
|
||||||
|
Host?.FocusViewer(this);
|
||||||
|
|
||||||
|
// Make the target the live working set so its dirty flag / document are current.
|
||||||
|
if (s != _active)
|
||||||
|
{
|
||||||
|
CommitActiveTextBox();
|
||||||
|
CancelTransientForSwitch();
|
||||||
|
if (_active != null) CaptureSessionState(_active);
|
||||||
|
SetActiveSession(s);
|
||||||
|
ApplySessionState(s);
|
||||||
|
RenderActiveSession();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CommitActiveTextBox();
|
||||||
|
CaptureSessionState(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isDirty)
|
||||||
|
{
|
||||||
|
var res = KillerDialog.Show(Host!.Window,
|
||||||
|
Loc("Str_Dlg_UnsavedClose"),
|
||||||
|
"KillerPDF", MessageBoxButton.YesNo, MessageBoxImage.Warning);
|
||||||
|
if (res != MessageBoxResult.Yes) { RebuildTabStrip(); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
try { _doc?.Close(); } catch { }
|
||||||
|
_doc = null;
|
||||||
|
|
||||||
|
int idx = _sessions.IndexOf(s);
|
||||||
|
_sessions.Remove(s);
|
||||||
|
_renderLru.Remove(s); // don't pin a closed tab's render cache in the LRU list
|
||||||
|
s.RenderCache.Clear();
|
||||||
|
s.RenderCacheSize.Clear();
|
||||||
|
CompactLohSoon(); // #122: give the freed bitmap memory back to the OS
|
||||||
|
|
||||||
|
if (_sessions.Count == 0)
|
||||||
|
{
|
||||||
|
App.RemoveSetting("LastFile"); // a manually emptied window won't reopen on launch
|
||||||
|
var blank = new DocumentSession();
|
||||||
|
_sessions.Add(blank);
|
||||||
|
SetActiveSession(blank);
|
||||||
|
ApplySessionState(blank);
|
||||||
|
ShowEmptyState();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var next = _sessions[Math.Min(idx, _sessions.Count - 1)];
|
||||||
|
SetActiveSession(next);
|
||||||
|
ApplySessionState(next);
|
||||||
|
if (next.Doc == null && next.DeferredPath != null) MaterializeDeferred(next);
|
||||||
|
else RenderActiveSession();
|
||||||
|
}
|
||||||
|
RebuildTabStrip();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Q: close every open document and reset to a single blank tab, with one combined warning
|
||||||
|
// if anything is unsaved (rather than a prompt per tab).
|
||||||
|
private void CloseAllTabs()
|
||||||
|
{
|
||||||
|
EnsureInitialSession();
|
||||||
|
CommitActiveTextBox();
|
||||||
|
if (_active != null) CaptureSessionState(_active);
|
||||||
|
|
||||||
|
var docTabs = _sessions.Where(t => t.Doc != null || t.DeferredPath != null).ToList();
|
||||||
|
if (docTabs.Count == 0) return;
|
||||||
|
|
||||||
|
if (docTabs.Any(t => t.IsDirty))
|
||||||
|
{
|
||||||
|
var res = KillerDialog.Show(Host!.Window, Loc("Str_Dlg_UnsavedCloseAll"),
|
||||||
|
"KillerPDF", MessageBoxButton.YesNo, MessageBoxImage.Warning);
|
||||||
|
if (res != MessageBoxResult.Yes) { RebuildTabStrip(); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var s in docTabs) { try { s.Doc?.Close(); } catch { } }
|
||||||
|
try { _doc?.Close(); } catch { }
|
||||||
|
_doc = null;
|
||||||
|
|
||||||
|
_sessions.Clear();
|
||||||
|
App.RemoveSetting("LastFile"); // a manually emptied window won't reopen on launch
|
||||||
|
var blank2 = new DocumentSession();
|
||||||
|
_sessions.Add(blank2);
|
||||||
|
SetActiveSession(blank2);
|
||||||
|
ApplySessionState(blank2);
|
||||||
|
ShowEmptyState();
|
||||||
|
RebuildTabStrip();
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenFromExternal / RestoreAndActivate moved BACK to Shell/ExternalOpen.cs on MainWindow.
|
||||||
|
// They are window chrome, not pane behavior: RestoreAndActivate drives WindowState,
|
||||||
|
// Activate() and Topmost, none of which exist on a UserControl, and App calls both on the
|
||||||
|
// window. Only the OpenInNewTab call inside them belongs to a pane, and that now routes
|
||||||
|
// through ActiveViewer like every other window -> viewer call.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace KillerPDF.Controls
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// This pane's tab surface, exposed to the window. Wrappers inside the same partial class, so
|
||||||
|
/// they can reach members PdfViewer.Tabs.cs keeps private; the Ext suffix exists only because a
|
||||||
|
/// wrapper cannot share a name with what it wraps.
|
||||||
|
///
|
||||||
|
/// The window calls these against ActiveViewer, so a shortcut or toolbar button acts on the
|
||||||
|
/// focused pane.
|
||||||
|
/// </summary>
|
||||||
|
public partial class PdfViewer
|
||||||
|
{
|
||||||
|
// ── Opening and closing ──────────────────────────────────────────────────────────────
|
||||||
|
internal void OpenInNewTabExt(string path) => OpenInNewTab(path);
|
||||||
|
internal void CloseTabExt(DocumentSession? s) => CloseTab(s);
|
||||||
|
internal void CloseAllTabsExt() => CloseAllTabs();
|
||||||
|
internal void CloseOtherTabsExt(DocumentSession? keep = null)
|
||||||
|
{
|
||||||
|
var target = keep ?? _active;
|
||||||
|
if (target != null) CloseOtherTabs(target);
|
||||||
|
}
|
||||||
|
internal void CycleTabExt(int dir) => CycleTab(dir);
|
||||||
|
internal void EnsureInitialSessionExt() => EnsureInitialSession();
|
||||||
|
internal void MaterializeDeferredExt(DocumentSession target) => MaterializeDeferred(target);
|
||||||
|
internal void SwitchToTabExt(DocumentSession target) => SwitchToTab(target);
|
||||||
|
|
||||||
|
// ── The load handshake (FileOperations / ImportAndZip drive this) ────────────────────
|
||||||
|
internal DocumentSession BeginTabLoadExt(out DocumentSession? prev, out bool createdNew)
|
||||||
|
=> BeginTabLoad(out prev, out createdNew);
|
||||||
|
internal void AbortTabLoadExt(DocumentSession target, DocumentSession? prev, bool createdNew)
|
||||||
|
=> AbortTabLoad(target, prev, createdNew);
|
||||||
|
|
||||||
|
// ── Session state ────────────────────────────────────────────────────────────────────
|
||||||
|
internal void CaptureSessionStateExt(DocumentSession s) => CaptureSessionState(s);
|
||||||
|
|
||||||
|
/// <summary>Fold this pane's live fields back into its own active session, if it has one.
|
||||||
|
/// The close path has to do this for BOTH panes before asking about unsaved work.</summary>
|
||||||
|
internal void CaptureActiveIfAny()
|
||||||
|
{
|
||||||
|
if (_active != null) CaptureSessionState(_active);
|
||||||
|
}
|
||||||
|
internal void ApplySessionStateExt(DocumentSession s) => ApplySessionState(s);
|
||||||
|
|
||||||
|
/// <summary>Swap this pane's active session into the window's shared document fields. The
|
||||||
|
/// counterpart to CaptureActiveIfAny; FocusPane runs both across a pane switch.
|
||||||
|
///
|
||||||
|
/// The empty-pane branch is not an optimization, it prevents cross-pane corruption. The
|
||||||
|
/// shared fields still describe the pane we just left, and EnsureInitialSession ends with
|
||||||
|
/// CaptureSessionState, which copies _doc, _annotations, _undoStack and the rest BY
|
||||||
|
/// REFERENCE. So the first session an empty pane created would alias the other pane's live
|
||||||
|
/// document: opening a file in one pane replaced the other's, and switching tabs in one
|
||||||
|
/// moved the other. Blanking the shared fields here means there is nothing to alias.</summary>
|
||||||
|
internal void ApplyActiveSessionIfAny()
|
||||||
|
{
|
||||||
|
if (_active != null) { ApplySessionState(_active); return; }
|
||||||
|
|
||||||
|
var blank = new DocumentSession(); // every collection field has its own initializer
|
||||||
|
_sessions.Add(blank);
|
||||||
|
SetActiveSession(blank);
|
||||||
|
ApplySessionState(blank);
|
||||||
|
ShowEmptyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Put this pane's active session back into the shared fields and nothing else -
|
||||||
|
/// pure assignment, no UI, and no session created if there is none. Used by WithOwnSession,
|
||||||
|
/// which runs from layout events and must not cause any further layout.</summary>
|
||||||
|
internal void RestoreActiveFieldsOnly()
|
||||||
|
{
|
||||||
|
if (_active != null) ApplySessionState(_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Run view math with THIS pane's document in the window's shared fields.
|
||||||
|
///
|
||||||
|
/// _doc, _viewMode, _fitMode, _zoomLevel and _gridColumns are WINDOW fields, but the
|
||||||
|
/// handlers that do view math - the viewport's SizeChanged, the resize-settle timer,
|
||||||
|
/// ReapplyGridOrFit - are per-pane: each pane's own ScrollViewer raises them. So an
|
||||||
|
/// unfocused pane whose viewport ticked was refitting itself against the FOCUSED pane's
|
||||||
|
/// document and fit mode, and writing that pane's zoom back out. That is why switching
|
||||||
|
/// tabs in one pane kept changing the other pane's view.
|
||||||
|
///
|
||||||
|
/// Swap this pane's session in, run, fold the view values back, then restore the focused
|
||||||
|
/// pane. Only the view fields are folded back, not a full CaptureSessionState: that also
|
||||||
|
/// writes DocStates to the registry, which a resize would do dozens of times a second.</summary>
|
||||||
|
private bool _inOwnSessionScope;
|
||||||
|
internal void WithOwnSession(System.Action work)
|
||||||
|
{
|
||||||
|
if (Host == null || _inOwnSessionScope || Host.IsViewerFocused(this))
|
||||||
|
{
|
||||||
|
work();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_inOwnSessionScope = true;
|
||||||
|
// The element accessors have to follow the fields. Swapping only the document state
|
||||||
|
// left the render path resolving PageHost / PreviewScroller through ActiveViewer, so
|
||||||
|
// this pane's fit measured the OTHER pane's viewport and painted into its tiles.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Host.RunWithViewerContext(this, () =>
|
||||||
|
{
|
||||||
|
if (_active != null)
|
||||||
|
{
|
||||||
|
ApplySessionState(_active);
|
||||||
|
// ApplySessionState deliberately leaves PageIndex to RenderActiveSession,
|
||||||
|
// which never runs on this path. Seed it from this pane's session.
|
||||||
|
State.CurrentPage = _active.PageIndex;
|
||||||
|
}
|
||||||
|
work();
|
||||||
|
if (_active != null)
|
||||||
|
{
|
||||||
|
_active.ZoomLevel = _zoomLevel;
|
||||||
|
_active.LastRenderZoom = _lastRenderZoom;
|
||||||
|
_active.Fit = _fitMode;
|
||||||
|
_active.View = _viewMode;
|
||||||
|
_active.GridColumns = _gridColumns;
|
||||||
|
_active.ScrollH = PagePreviewPanel?.HorizontalOffset ?? _active.ScrollH;
|
||||||
|
_active.ScrollV = PagePreviewPanel?.VerticalOffset ?? _active.ScrollV;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_inOwnSessionScope = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>This pane's sidebar thumbnails, kept so focusing it again does not re-decode the
|
||||||
|
/// document. Read by RestorePageListForActivePane (PageOperations.cs).</summary>
|
||||||
|
internal PageThumbnailVm[]? ThumbCache { get; set; }
|
||||||
|
internal string? ThumbCacheFile { get; set; }
|
||||||
|
|
||||||
|
/// <summary>This pane's thumbnail loader cancellation. PER PANE, not per window: one shared
|
||||||
|
/// token meant focusing either pane canceled whatever the other was still decoding, and
|
||||||
|
/// since the half-filled cache still matched the page count it counted as usable - so the
|
||||||
|
/// list re-seated with the labels and no pictures, permanently.</summary>
|
||||||
|
internal System.Threading.CancellationTokenSource? ThumbCts { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Highlight this pane's current page after the list is re-seated: assigning
|
||||||
|
/// ItemsSource clears the selection.</summary>
|
||||||
|
internal int CurrentPageIndex => State.CurrentPage;
|
||||||
|
|
||||||
|
internal void SyncPageListSelection(int? preservedPage = null)
|
||||||
|
{
|
||||||
|
if (preservedPage.HasValue) State.CurrentPage = preservedPage.Value;
|
||||||
|
if (State.CurrentPage < 0) return;
|
||||||
|
Host?.ViewerPageChanged(this, State.CurrentPage);
|
||||||
|
if (Host != null) Host.PageJumpText = (State.CurrentPage + 1).ToString();
|
||||||
|
Host?.EnsureSidebarPageVisible(this, State.CurrentPage);
|
||||||
|
}
|
||||||
|
internal void SaveDocStateExt(string? path, FitMode fit, double zoom, ViewMode view, int page)
|
||||||
|
=> SaveDocState(path, fit, zoom, view, page);
|
||||||
|
internal bool TryGetDocStateExt(string? path, out FitMode fit, out double zoom,
|
||||||
|
out ViewMode view, out int page)
|
||||||
|
=> TryGetDocState(path, out fit, out zoom, out view, out page);
|
||||||
|
|
||||||
|
// ── Strip and render ─────────────────────────────────────────────────────────────────
|
||||||
|
internal void InitTabStripExt() => InitTabStrip();
|
||||||
|
internal void RebuildTabStripExt() => RebuildTabStrip();
|
||||||
|
/// <summary>The band changed width. Kept under the old name because the window still wires
|
||||||
|
/// the focused pane's SizeChanged to it; each pane also raises its own now, and the call is
|
||||||
|
/// guarded and idempotent, so the two agreeing costs nothing.</summary>
|
||||||
|
internal void ScheduleTabReflowExt() => TabBarResized();
|
||||||
|
internal void RenderActiveSessionExt() => RenderActiveSession();
|
||||||
|
internal void ShowEmptyStateExt() => ShowEmptyState();
|
||||||
|
internal void FlushAllRenderCachesExt() => FlushAllRenderCaches();
|
||||||
|
internal void InvalidateRenderCacheExt(DocumentSession? s) => InvalidateRenderCache(s);
|
||||||
|
|
||||||
|
/// <summary>Make a brand-new empty session the active one. The startup restore builds the
|
||||||
|
/// session list itself, so it needs to place the result rather than go through
|
||||||
|
/// EnsureInitialSession.</summary>
|
||||||
|
internal void SetSessionsExt(IEnumerable<DocumentSession> sessions, DocumentSession? active)
|
||||||
|
{
|
||||||
|
_sessions.Clear();
|
||||||
|
foreach (var s in sessions) _sessions.Add(s); // ObservableCollection has no AddRange
|
||||||
|
SetActiveSession(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Build a deferred (lazy) session for the restore path - a tab that shows its
|
||||||
|
/// title but does not load its document until it is first switched to.</summary>
|
||||||
|
internal static DocumentSession MakeDeferredSession(string path)
|
||||||
|
=> new() { OriginalFile = path, CurrentFile = path, DeferredPath = path };
|
||||||
|
|
||||||
|
// ── This pane's strip elements, for the window chrome that still positions them ──────
|
||||||
|
// AppScale scales them, FullScreen hides them, SidebarLayout flips their margins - all of
|
||||||
|
// which now have to act on BOTH panes rather than one window-level band.
|
||||||
|
internal System.Windows.Controls.Border TabStripBorderCtl => TabStripBorder;
|
||||||
|
internal System.Windows.Controls.Border TabStripFadeCtl => TabStripFade;
|
||||||
|
internal System.Windows.Controls.Border TabBarRingCtl => TabBarRing;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,868 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Animation;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using System.Windows.Shapes;
|
||||||
|
using Docnet.Core;
|
||||||
|
using Docnet.Core.Models;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using PdfSharpCore.Drawing;
|
||||||
|
using PdfSharpCore.Pdf;
|
||||||
|
using PdfSharpCore.Pdf.IO;
|
||||||
|
using KillerPDF.Services;
|
||||||
|
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
|
||||||
|
|
||||||
|
namespace KillerPDF.Controls
|
||||||
|
{
|
||||||
|
// Moved from Shell/TextEditing.cs; the namespace and class line are the only changes. Window
|
||||||
|
// members spelled bare here resolve through PdfViewer.Bridge.cs.
|
||||||
|
public partial class PdfViewer
|
||||||
|
{
|
||||||
|
// ============================================================
|
||||||
|
// Inline text editing (double-click)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// While a paired text is being re-edited, trace its cover with a dashed outline so the cover stays
|
||||||
|
// visible (its opaque fill often matches the page, so without this it looks like the cover vanished).
|
||||||
|
private void ShowReeditCoverOutline(string pairId, int pageIdx)
|
||||||
|
{
|
||||||
|
RemoveReeditCoverOutline();
|
||||||
|
if (pairId.Length == 0 || !_annotations.TryGetValue(pageIdx, out var list)) return;
|
||||||
|
var cover = list.OfType<CoverAnnotation>().FirstOrDefault(c => c.PairId == pairId);
|
||||||
|
if (cover is null) return;
|
||||||
|
double inv = 1.0;
|
||||||
|
if (_activeCanvas.LayoutTransform is ScaleTransform st && st.ScaleX > 0.0001) inv = 1.0 / st.ScaleX;
|
||||||
|
var pb = cover.Bounds;
|
||||||
|
_reeditCoverOutline = new Rectangle
|
||||||
|
{
|
||||||
|
Width = pb.Width + 4,
|
||||||
|
Height = pb.Height + 4,
|
||||||
|
Stroke = DarkerAccentBrush(),
|
||||||
|
StrokeThickness = 1.5 * inv,
|
||||||
|
StrokeDashArray = [4, 3],
|
||||||
|
Fill = Brushes.Transparent,
|
||||||
|
IsHitTestVisible = false
|
||||||
|
};
|
||||||
|
Canvas.SetLeft(_reeditCoverOutline, pb.X - 2);
|
||||||
|
Canvas.SetTop(_reeditCoverOutline, pb.Y - 2);
|
||||||
|
_activeCanvas.Children.Add(_reeditCoverOutline);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveReeditCoverOutline()
|
||||||
|
{
|
||||||
|
if (_reeditCoverOutline is not null)
|
||||||
|
{
|
||||||
|
(_reeditCoverOutline.Parent as Canvas)?.Children.Remove(_reeditCoverOutline);
|
||||||
|
_reeditCoverOutline = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heuristic for a broken glyph->Unicode CMap (common on OCR'd scans): the extracted text comes out
|
||||||
|
// as mojibake - replacement chars, private-use glyphs, or words peppered with currency/math symbols.
|
||||||
|
// We don't pre-fill an in-place edit from text that looks like this. Conservative on purpose so clean
|
||||||
|
// PDFs are never flagged; all-letter garbling (wrong letters that are still valid) can't be caught.
|
||||||
|
private static bool LooksGarbled(string s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(s)) return false;
|
||||||
|
const string ok = ".,;:!?'\"()[]{}-/\\&%@#*+=<>|~`^_$";
|
||||||
|
int letters = 0, weird = 0, total = 0;
|
||||||
|
foreach (char c in s)
|
||||||
|
{
|
||||||
|
if (char.IsWhiteSpace(c)) continue;
|
||||||
|
total++;
|
||||||
|
if (char.IsLetterOrDigit(c)) { letters++; continue; }
|
||||||
|
if (ok.IndexOf(c) >= 0) continue; // ordinary punctuation is fine
|
||||||
|
weird++; // replacement / PUA / stray symbol = mapping break
|
||||||
|
}
|
||||||
|
if (total == 0) return false;
|
||||||
|
return (double)weird / total > 0.15 || (double)letters / total < 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EditTextAtPosition(Point canvasPos, int pageIdx)
|
||||||
|
{
|
||||||
|
if (_currentFile is null || !_renderDims.ContainsKey(pageIdx)) return;
|
||||||
|
|
||||||
|
// Commit any existing edit first
|
||||||
|
if (_activeTextBox is not null)
|
||||||
|
{
|
||||||
|
CommitActiveTextBox();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-edit a user-placed text annotation: lift it into an editable box
|
||||||
|
// pre-filled with its content, size (shown in points), and color.
|
||||||
|
if (_annotations.TryGetValue(pageIdx, out var placedPage))
|
||||||
|
{
|
||||||
|
var placed = placedPage.OfType<TextAnnotation>()
|
||||||
|
.LastOrDefault(a => HitTestAnnotation(a, canvasPos, out _));
|
||||||
|
if (placed is not null)
|
||||||
|
{
|
||||||
|
var pcol = placed.GetColor();
|
||||||
|
_textColor = pcol;
|
||||||
|
_textOpacity = pcol.A; // keep the opacity slider in sync with the edited text
|
||||||
|
_textFillColor = placed.GetFill(); // and the fill swatches in sync with the box
|
||||||
|
double syp = 1.0;
|
||||||
|
if (_doc is not null && _renderDims.TryGetValue(pageIdx, out var prd) && prd.h > 0)
|
||||||
|
syp = _doc.Pages[pageIdx].Height.Point / prd.h;
|
||||||
|
_textFontSize = Math.Max(1, Math.Round(placed.FontSize * syp));
|
||||||
|
// Sync the bar's typeface + B/I/S to the box being re-edited.
|
||||||
|
_textFontName = string.IsNullOrEmpty(placed.FontName) ? "Segoe UI" : placed.FontName;
|
||||||
|
_textBold = placed.Bold; _textItalic = placed.Italic; _textStrike = placed.Strike; _textUnderline = placed.Underline;
|
||||||
|
|
||||||
|
_reeditOriginal = placed;
|
||||||
|
placedPage.Remove(placed);
|
||||||
|
RenderAllAnnotations(pageIdx);
|
||||||
|
// Keep the paired cover visible (outlined) for the duration of the edit.
|
||||||
|
ShowReeditCoverOutline(placed.PairId, pageIdx);
|
||||||
|
|
||||||
|
var ptb = new TextBox
|
||||||
|
{
|
||||||
|
Text = placed.Content,
|
||||||
|
Background = TextEditBackground(),
|
||||||
|
Foreground = new SolidColorBrush(pcol),
|
||||||
|
BorderBrush = (SolidColorBrush)FindResource("PrimaryBrush"),
|
||||||
|
SelectionBrush = AccentBrush(),
|
||||||
|
CaretBrush = new SolidColorBrush(pcol),
|
||||||
|
Template = FlatTextBoxTemplate(),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
FontFamily = UiKit.UiFont,
|
||||||
|
FontSize = placed.FontSize,
|
||||||
|
Width = placed.Width > 0 ? placed.Width : TextBoxDefaultWidth,
|
||||||
|
MinHeight = 24,
|
||||||
|
Padding = new Thickness(2),
|
||||||
|
AcceptsReturn = true,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
Tag = pageIdx
|
||||||
|
};
|
||||||
|
Canvas.SetLeft(ptb, placed.Position.X);
|
||||||
|
Canvas.SetTop(ptb, placed.Position.Y);
|
||||||
|
_activeCanvas.Children.Add(ptb);
|
||||||
|
_activeTextBox = ptb;
|
||||||
|
StyleEditBox(ptb); // restore the box's typeface + B/I/S
|
||||||
|
ptb.PreviewKeyDown += TextBox_PreviewKeyDown;
|
||||||
|
ptb.Loaded += (s, ev) => { ptb.Focus(); Keyboard.Focus(ptb); ptb.SelectAll(); ptb.LostFocus += TextBox_LostFocus; AttachTextEditResizeHandles(ptb); };
|
||||||
|
ShowTextSettings();
|
||||||
|
SetStatus(Loc("Str_St_EditingText"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The click landed on an existing text cover but not its replacement text (handled above).
|
||||||
|
// Don't start a fresh detection over an edit that already exists - that would stack a second
|
||||||
|
// cover+text. Bail so the user grabs the existing text/cover instead of duplicating it.
|
||||||
|
if (_annotations.TryGetValue(pageIdx, out var coverPage)
|
||||||
|
&& coverPage.OfType<CoverAnnotation>().Any(c => { var b = c.Bounds; b.Inflate(6, 6); return b.Contains(canvasPos); }))
|
||||||
|
{
|
||||||
|
SetStatus(Loc("Str_St_AlreadyEditHere"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (renderW, renderH) = _renderDims[pageIdx];
|
||||||
|
|
||||||
|
using var pigDoc = PdfPigDoc.Open(_currentFile);
|
||||||
|
if (pageIdx >= pigDoc.NumberOfPages) return;
|
||||||
|
var page = pigDoc.GetPage(pageIdx + 1);
|
||||||
|
|
||||||
|
double pdfW = page.Width;
|
||||||
|
double pdfH = page.Height;
|
||||||
|
double sxInv = (double)renderW / pdfW; // pdf->canvas
|
||||||
|
double syInv = (double)renderH / pdfH;
|
||||||
|
|
||||||
|
// Convert all words to canvas coordinates upfront
|
||||||
|
var canvasWords = page.GetWords().Select(w =>
|
||||||
|
{
|
||||||
|
double cx = w.BoundingBox.Left * sxInv;
|
||||||
|
double cy = renderH - (w.BoundingBox.Top * syInv);
|
||||||
|
double cw = (w.BoundingBox.Right - w.BoundingBox.Left) * sxInv;
|
||||||
|
double ch = (w.BoundingBox.Top - w.BoundingBox.Bottom) * syInv;
|
||||||
|
return new { Word = w, Rect = new Rect(cx, cy, cw, ch) };
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
if (canvasWords.Count == 0)
|
||||||
|
{
|
||||||
|
// Scanned / image-only page: no text layer to detect. Fall back to a manual edit -
|
||||||
|
// drop a cover + empty text box at the click so the user can white out the scanned
|
||||||
|
// text and type over it by hand (resize the cover to fit).
|
||||||
|
double mf = Math.Max(_textFontSize * syInv, 8); // current text size in canvas units
|
||||||
|
StartCoverTextEdit(pageIdx, new Rect(canvasPos.X, canvasPos.Y, 200, mf * 1.35), "", mf, "Segoe UI", syInv);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find words on the same line as the click (Y overlap with tolerance)
|
||||||
|
var clickY = canvasPos.Y;
|
||||||
|
var lineWords = canvasWords
|
||||||
|
.Where(cw => clickY >= cw.Rect.Top - 3 && clickY <= cw.Rect.Bottom + 3)
|
||||||
|
.OrderBy(cw => cw.Rect.Left) // strictly left-to-right
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (lineWords.Count == 0)
|
||||||
|
{
|
||||||
|
// Try nearest line within 20px
|
||||||
|
var nearest = canvasWords
|
||||||
|
.OrderBy(cw => Math.Abs((cw.Rect.Top + cw.Rect.Bottom) / 2 - clickY))
|
||||||
|
.First();
|
||||||
|
double nearMidY = (nearest.Rect.Top + nearest.Rect.Bottom) / 2;
|
||||||
|
lineWords = [..canvasWords
|
||||||
|
.Where(cw => Math.Abs((cw.Rect.Top + cw.Rect.Bottom) / 2 - nearMidY) < 5)
|
||||||
|
.OrderBy(cw => cw.Rect.Left)];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineWords.Count == 0)
|
||||||
|
{
|
||||||
|
SetStatus(Loc("Str_St_NoTextLine"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Narrow to the contiguous run of words around the click. Words at the same Y in a
|
||||||
|
// second column are separated by a large horizontal gap, so stop there instead of
|
||||||
|
// merging both columns into one edit (the "weird text" / page-spanning edit).
|
||||||
|
if (lineWords.Count > 1)
|
||||||
|
{
|
||||||
|
int ci = 0; double bestDx = double.MaxValue;
|
||||||
|
for (int i = 0; i < lineWords.Count; i++)
|
||||||
|
{
|
||||||
|
var r = lineWords[i].Rect;
|
||||||
|
double dx = canvasPos.X < r.Left ? r.Left - canvasPos.X
|
||||||
|
: canvasPos.X > r.Right ? canvasPos.X - r.Right : 0;
|
||||||
|
if (dx < bestDx) { bestDx = dx; ci = i; }
|
||||||
|
}
|
||||||
|
double gapMax = Math.Max(lineWords[ci].Rect.Height * 1.5, 24); // word spacing is small; a column gap is large
|
||||||
|
int lo = ci, hi = ci;
|
||||||
|
while (lo > 0 && lineWords[lo].Rect.Left - lineWords[lo - 1].Rect.Right <= gapMax) lo--;
|
||||||
|
while (hi < lineWords.Count - 1 && lineWords[hi + 1].Rect.Left - lineWords[hi].Rect.Right <= gapMax) hi++;
|
||||||
|
lineWords = lineWords.GetRange(lo, hi - lo + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute bounding box in canvas space
|
||||||
|
double cLeft = lineWords.Min(w => w.Rect.Left);
|
||||||
|
double cTop = lineWords.Min(w => w.Rect.Top);
|
||||||
|
double cRight = lineWords.Max(w => w.Rect.Right);
|
||||||
|
double cBottom = lineWords.Max(w => w.Rect.Bottom);
|
||||||
|
double cWidth = cRight - cLeft;
|
||||||
|
double cHeight = cBottom - cTop;
|
||||||
|
|
||||||
|
string lineText = string.Join(" ", lineWords.Select(w => w.Word.Text));
|
||||||
|
|
||||||
|
// If this line is already covered by an edit, don't detect it again - that just stacks a
|
||||||
|
// duplicate cover+text on top of the existing one. The original PDF text under a cover is
|
||||||
|
// "consumed": re-edit by clicking the replacement text instead.
|
||||||
|
var lineRect = new Rect(cLeft, cTop, Math.Max(1, cWidth), Math.Max(1, cHeight));
|
||||||
|
if (_annotations.TryGetValue(pageIdx, out var coveredPage)
|
||||||
|
&& coveredPage.OfType<CoverAnnotation>().Any(c => c.Bounds.IntersectsWith(lineRect)))
|
||||||
|
{
|
||||||
|
SetStatus(Loc("Str_St_LineAlreadyEdited"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get actual font info from PdfPig letter data
|
||||||
|
double canvasFontSize = cHeight * 0.75; // fallback
|
||||||
|
string fontName = "Segoe UI"; // fallback
|
||||||
|
bool fontBold = false;
|
||||||
|
bool fontItalic = false;
|
||||||
|
var firstWord = lineWords.First().Word;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (firstWord.Letters.Count > 0)
|
||||||
|
{
|
||||||
|
var letter = firstWord.Letters[0];
|
||||||
|
// PointSize is the glyph size in points. FontSize is the size as written in the
|
||||||
|
// content stream, which only matches the point size when the text matrix doesn't
|
||||||
|
// scale: a generator that emits "/F1 1 Tf" and scales through Tm reports FontSize
|
||||||
|
// 1, which collapsed the replacement onto the floor below and read back as 3pt
|
||||||
|
// (#163). Fall back to FontSize, then to the line-height estimate above, since
|
||||||
|
// PointSize can be 0 on fonts with no usable metrics (e.g. some Type3).
|
||||||
|
double pdfFontPts = letter.PointSize > 0 ? letter.PointSize : letter.FontSize;
|
||||||
|
if (pdfFontPts > 0)
|
||||||
|
canvasFontSize = pdfFontPts * syInv;
|
||||||
|
|
||||||
|
// Font family from the LETTER, never the word (#166, thanks Ryokoxx):
|
||||||
|
// Word.FontName joins its letters' names ("Helvetica Helvetica Helvetica
|
||||||
|
// ..."), which FontFamily cannot resolve - so that fallback landed on the
|
||||||
|
// default font, exactly where no fallback at all would have. The outer
|
||||||
|
// catch already covers a read that throws, so this needs no inner one.
|
||||||
|
string? rawFont = letter.FontName;
|
||||||
|
if (!string.IsNullOrEmpty(rawFont))
|
||||||
|
{
|
||||||
|
var detected = PdfFontStyle.FromPdfName(rawFont!);
|
||||||
|
fontName = detected.Family;
|
||||||
|
fontBold = detected.Bold;
|
||||||
|
fontItalic = detected.Italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* use fallbacks */ }
|
||||||
|
|
||||||
|
// Drop the cover + editable text box for the detected line. Detected size carries the
|
||||||
|
// EditTextSizeCorrection (WPF renders the source point size ~25% large); manual edits don't.
|
||||||
|
// Scanned PDFs with a broken glyph->Unicode map extract as mojibake; in that case start the
|
||||||
|
// box empty (like a manual edit) instead of pre-filling garbage - the user types over the
|
||||||
|
// whited-out original.
|
||||||
|
string prefill = LooksGarbled(lineText) ? "" : lineText;
|
||||||
|
StartCoverTextEdit(pageIdx, new Rect(cLeft, cTop, cWidth, cHeight), prefill,
|
||||||
|
Math.Max(canvasFontSize * EditTextSizeCorrection, 8), fontName, syInv,
|
||||||
|
fontBold, fontItalic);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
SetStatus(string.Format(Loc("Str_St_TextEditError"), ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drops an opaque cover at the given line and opens an editable text box on top of it - the two
|
||||||
|
// halves of an in-place edit. Used for a detected PDF-text line and, on a scanned page with no
|
||||||
|
// text layer, for a manual edit at the click point. boxFontCanvas is the on-canvas font size;
|
||||||
|
// the cover fill and text ink are sampled from the page so the edit blends in.
|
||||||
|
private void StartCoverTextEdit(int pageIdx, Rect lineRect, string text, double boxFontCanvas,
|
||||||
|
string fontName, double syInv, bool bold = false, bool italic = false)
|
||||||
|
{
|
||||||
|
double cLeft = lineRect.X, cTop = lineRect.Y, cWidth = lineRect.Width, cHeight = lineRect.Height;
|
||||||
|
// Pair id shared with the replacement text - the cover renders dashed while paired.
|
||||||
|
var cover = new CoverAnnotation
|
||||||
|
{
|
||||||
|
PageIndex = pageIdx,
|
||||||
|
PairId = Guid.NewGuid().ToString("N"),
|
||||||
|
Bounds = new Rect(cLeft - 3, cTop - 3, cWidth + 6, cHeight + 6)
|
||||||
|
};
|
||||||
|
var sampleRect = new Rect(cLeft, cTop, cWidth, cHeight);
|
||||||
|
Color coverBg = SampleCoverColor(pageIdx, sampleRect);
|
||||||
|
Color inkColor = SampleTextColor(pageIdx, sampleRect, coverBg);
|
||||||
|
cover.SetColor(coverBg);
|
||||||
|
_textColor = inkColor; _textOpacity = inkColor.A;
|
||||||
|
_textFontSize = Math.Max(1, Math.Round(boxFontCanvas / syInv)); // canvas units -> points
|
||||||
|
// Replacing raw PDF text starts from the detected font and its face styling. PDF fonts
|
||||||
|
// encode bold and italic in the font name, so resetting these flags made every detected
|
||||||
|
// line plain as soon as it was double-clicked (#182).
|
||||||
|
_textFontName = string.IsNullOrEmpty(fontName) ? "Segoe UI" : fontName;
|
||||||
|
_textBold = bold;
|
||||||
|
_textItalic = italic;
|
||||||
|
_textStrike = _textUnderline = false;
|
||||||
|
_pendingEditWasDirty = _isDirty; // capture before the cover dirties the doc
|
||||||
|
if (!_annotations.ContainsKey(pageIdx)) _annotations[pageIdx] = [];
|
||||||
|
_annotations[pageIdx].Add(cover);
|
||||||
|
_pendingCover = cover;
|
||||||
|
MarkDirty();
|
||||||
|
RenderAllAnnotations(pageIdx);
|
||||||
|
|
||||||
|
var tb = new TextBox
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
Background = Brushes.Transparent, // the opaque cover behind supplies the backdrop
|
||||||
|
Foreground = new SolidColorBrush(inkColor),
|
||||||
|
BorderBrush = (SolidColorBrush)FindResource("PrimaryBrush"),
|
||||||
|
SelectionBrush = AccentBrush(),
|
||||||
|
CaretBrush = new SolidColorBrush(inkColor),
|
||||||
|
Template = FlatTextBoxTemplate(),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
FontFamily = new FontFamily(fontName),
|
||||||
|
FontSize = boxFontCanvas,
|
||||||
|
Width = Math.Max(cWidth + 20, 80),
|
||||||
|
MinHeight = 24,
|
||||||
|
Padding = new Thickness(2, 0, 2, 0),
|
||||||
|
AcceptsReturn = true,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
Tag = pageIdx
|
||||||
|
};
|
||||||
|
Canvas.SetLeft(tb, cLeft);
|
||||||
|
Canvas.SetTop(tb, cTop);
|
||||||
|
_activeCanvas.Children.Add(tb);
|
||||||
|
_activeTextBox = tb;
|
||||||
|
StyleEditBox(tb);
|
||||||
|
tb.PreviewKeyDown += TextBox_PreviewKeyDown;
|
||||||
|
tb.Loaded += (s, ev) => { tb.Focus(); Keyboard.Focus(tb); tb.SelectAll(); tb.LostFocus += TextBox_LostFocus; AttachTextEditResizeHandles(tb); };
|
||||||
|
ShowTextSettings();
|
||||||
|
SetStatus(string.IsNullOrEmpty(text)
|
||||||
|
? "Type your text, then drag the cover over the original - Enter to save, Escape to cancel"
|
||||||
|
: "Editing text - change size/color above, Enter to save, Escape to cancel");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Text box handling
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// A flat TextBox template (just a themed border hosting the text) so the OS default focus border
|
||||||
|
// and selection chrome - the stray WPF "blue" - never show on the in-canvas text editor.
|
||||||
|
private static ControlTemplate FlatTextBoxTemplate()
|
||||||
|
{
|
||||||
|
var b = new FrameworkElementFactory(typeof(Border));
|
||||||
|
b.SetBinding(Border.BackgroundProperty, new System.Windows.Data.Binding("Background")
|
||||||
|
{ RelativeSource = new System.Windows.Data.RelativeSource(System.Windows.Data.RelativeSourceMode.TemplatedParent) });
|
||||||
|
b.SetBinding(Border.BorderBrushProperty, new System.Windows.Data.Binding("BorderBrush")
|
||||||
|
{ RelativeSource = new System.Windows.Data.RelativeSource(System.Windows.Data.RelativeSourceMode.TemplatedParent) });
|
||||||
|
b.SetBinding(Border.BorderThicknessProperty, new System.Windows.Data.Binding("BorderThickness")
|
||||||
|
{ RelativeSource = new System.Windows.Data.RelativeSource(System.Windows.Data.RelativeSourceMode.TemplatedParent) });
|
||||||
|
b.SetValue(Border.CornerRadiusProperty, new CornerRadius(2));
|
||||||
|
var sv = new FrameworkElementFactory(typeof(ScrollViewer)) { Name = "PART_ContentHost" };
|
||||||
|
b.AppendChild(sv);
|
||||||
|
return new ControlTemplate(typeof(TextBox)) { VisualTree = b };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background shown WHILE editing a text box: the chosen fill if one is set, otherwise a faint
|
||||||
|
// translucent neutral gray. Gray (not white) so the empty editable box stays visible on both
|
||||||
|
// light/white pages and dark pages; it's only shown during editing and never committed.
|
||||||
|
private Brush TextEditBackground()
|
||||||
|
=> _textFillColor.A > 0 ? new SolidColorBrush(_textFillColor)
|
||||||
|
: new SolidColorBrush(Color.FromArgb(64, 128, 128, 128));
|
||||||
|
|
||||||
|
// True when 'pos' (in _activeCanvas coordinates) falls inside the text box currently being
|
||||||
|
// edited AND that box lives on _activeCanvas. Used so a click inside the box doesn't get
|
||||||
|
// treated as a request to place a new one (the Grid-view "box jumps to cursor" bug).
|
||||||
|
private bool ClickInsideActiveTextBox(Point pos)
|
||||||
|
{
|
||||||
|
if (_activeTextBox is null || !ReferenceEquals(_activeTextBox.Parent, _activeCanvas)) return false;
|
||||||
|
double x = Canvas.GetLeft(_activeTextBox), y = Canvas.GetTop(_activeTextBox);
|
||||||
|
if (double.IsNaN(x) || double.IsNaN(y)) return false;
|
||||||
|
double w = _activeTextBox.ActualWidth > 0 ? _activeTextBox.ActualWidth : _activeTextBox.Width;
|
||||||
|
double h = _activeTextBox.ActualHeight > 0 ? _activeTextBox.ActualHeight : Math.Max(_activeTextBox.MinHeight, 24);
|
||||||
|
return pos.X >= x && pos.X <= x + w && pos.Y >= y && pos.Y <= y + h;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PlaceTextBox(Point pos, int pageIdx)
|
||||||
|
{
|
||||||
|
// _textFontSize is a point size; convert to the page's canvas (render-dim) units so
|
||||||
|
// it renders and exports as real points. DrawAnnotationsOnDocument multiplies by
|
||||||
|
// sy = page.Height.Point / renderH, so dividing by sy here makes "14" export as 14pt.
|
||||||
|
double fontCanvas = _textFontSize;
|
||||||
|
if (_doc is not null && _renderDims.TryGetValue(pageIdx, out var rdims) && rdims.h > 0)
|
||||||
|
{
|
||||||
|
double sy = _doc.Pages[pageIdx].Height.Point / rdims.h;
|
||||||
|
if (sy > 0) fontCanvas = _textFontSize / sy;
|
||||||
|
}
|
||||||
|
// A default-size box dropped at the click point. Width is fixed (text wraps to it) and the
|
||||||
|
// box auto-grows downward as you type; resize the width later via the corner handles.
|
||||||
|
var tb = new TextBox
|
||||||
|
{
|
||||||
|
Background = TextEditBackground(),
|
||||||
|
Foreground = new SolidColorBrush(_textColor),
|
||||||
|
BorderBrush = (SolidColorBrush)FindResource("PrimaryBrush"),
|
||||||
|
SelectionBrush = AccentBrush(),
|
||||||
|
CaretBrush = new SolidColorBrush(_textColor),
|
||||||
|
Template = FlatTextBoxTemplate(),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
FontFamily = UiKit.UiFont,
|
||||||
|
FontSize = fontCanvas,
|
||||||
|
Width = TextBoxDefaultWidth,
|
||||||
|
MinHeight = 24,
|
||||||
|
Padding = new Thickness(2),
|
||||||
|
AcceptsReturn = true,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
Tag = pageIdx
|
||||||
|
};
|
||||||
|
Canvas.SetLeft(tb, pos.X);
|
||||||
|
Canvas.SetTop(tb, pos.Y);
|
||||||
|
_activeCanvas.Children.Add(tb);
|
||||||
|
_activeTextBox = tb;
|
||||||
|
StyleEditBox(tb); // current typeface + B/I/S
|
||||||
|
tb.PreviewKeyDown += TextBox_PreviewKeyDown;
|
||||||
|
tb.LostFocus += TextBox_LostFocus;
|
||||||
|
// Focus the box and attach its live resize handles once laid out. Loaded fires on first
|
||||||
|
// placement; a dispatcher fallback covers re-entry (Text tool -> Select -> Text again),
|
||||||
|
// where Loaded may have already run - without it the new box silently took no typing and
|
||||||
|
// showed no handles. Activate is idempotent (guards against double focus/handle attach).
|
||||||
|
void Activate()
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(_activeTextBox, tb)) return;
|
||||||
|
tb.Focus();
|
||||||
|
Keyboard.Focus(tb);
|
||||||
|
if (!ReferenceEquals(_tehBox, tb)) AttachTextEditResizeHandles(tb);
|
||||||
|
}
|
||||||
|
tb.Loaded += (s, e) => Activate();
|
||||||
|
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, new Action(Activate));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Live resize handles around the editing TextBox ──────────────────────────────
|
||||||
|
// Corner squares the user can drag to resize the box mid-edit, then keep typing. The
|
||||||
|
// box auto-grows in height until a handle is dragged, after which the height is free-form.
|
||||||
|
private void AttachTextEditResizeHandles(TextBox tb)
|
||||||
|
{
|
||||||
|
RemoveTextEditHandles();
|
||||||
|
_tehBox = tb;
|
||||||
|
double inv = 1.0;
|
||||||
|
if (_activeCanvas.LayoutTransform is ScaleTransform sc && sc.ScaleX > 0.0001) inv = 1.0 / sc.ScaleX;
|
||||||
|
double hs = 12 * inv;
|
||||||
|
foreach (string tag in new[] { "NW", "NE", "SE", "SW" })
|
||||||
|
{
|
||||||
|
var hd = new Rectangle
|
||||||
|
{
|
||||||
|
Width = hs,
|
||||||
|
Height = hs,
|
||||||
|
Fill = AccentBrush(),
|
||||||
|
Stroke = Brushes.White,
|
||||||
|
StrokeThickness = 1 * inv,
|
||||||
|
Cursor = (tag is "NW" or "SE") ? Cursors.SizeNWSE : Cursors.SizeNESW,
|
||||||
|
Focusable = false, // so grabbing a handle does not blur (and commit) the TextBox
|
||||||
|
Tag = tag
|
||||||
|
};
|
||||||
|
Panel.SetZIndex(hd, 200);
|
||||||
|
// Hit detection + drag are handled in the canvas gesture handlers (which run as
|
||||||
|
// PreviewMouseLeftButtonDown and would otherwise intercept the click), mirroring the
|
||||||
|
// committed-annotation resize handles.
|
||||||
|
_textEditHandles.Add(hd);
|
||||||
|
_activeCanvas.Children.Add(hd);
|
||||||
|
}
|
||||||
|
tb.SizeChanged += TextEditBox_SizeChanged;
|
||||||
|
LayoutTextEditHandles();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TextEditBox_SizeChanged(object sender, SizeChangedEventArgs e) => LayoutTextEditHandles();
|
||||||
|
|
||||||
|
private void LayoutTextEditHandles()
|
||||||
|
{
|
||||||
|
if (_tehBox is null || _textEditHandles.Count == 0) return;
|
||||||
|
double x = Canvas.GetLeft(_tehBox), y = Canvas.GetTop(_tehBox);
|
||||||
|
double w = _tehBox.ActualWidth > 0 ? _tehBox.ActualWidth : _tehBox.Width;
|
||||||
|
double h = _tehBox.ActualHeight;
|
||||||
|
foreach (var hd in _textEditHandles)
|
||||||
|
{
|
||||||
|
double hsz = hd.Width;
|
||||||
|
(double cx, double cy) = (hd.Tag as string) switch
|
||||||
|
{
|
||||||
|
"NW" => (x, y),
|
||||||
|
"NE" => (x + w, y),
|
||||||
|
"SW" => (x, y + h),
|
||||||
|
_ => (x + w, y + h) // SE
|
||||||
|
};
|
||||||
|
Canvas.SetLeft(hd, cx - hsz / 2);
|
||||||
|
Canvas.SetTop(hd, cy - hsz / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveTextEditHandles()
|
||||||
|
{
|
||||||
|
if (_tehBox is not null) _tehBox.SizeChanged -= TextEditBox_SizeChanged;
|
||||||
|
foreach (var hd in _textEditHandles) RemoveFromParent(hd);
|
||||||
|
_textEditHandles.Clear();
|
||||||
|
_tehBox = null;
|
||||||
|
_draggingTextEditHandle = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a canvas child from whatever Panel actually parents it, instead of assuming it lives
|
||||||
|
// on _activeCanvas. In continuous/grid view _activeCanvas follows the mouse to whichever page
|
||||||
|
// was last clicked, so a text-edit box, its whiteout, or its handles - placed earlier on a
|
||||||
|
// different page's canvas - would otherwise survive removal and become orphaned: still painted,
|
||||||
|
// but unreachable by Delete, Clear All, or resize. Its live Parent is always the correct host.
|
||||||
|
private static void RemoveFromParent(UIElement? el)
|
||||||
|
{
|
||||||
|
if (el is FrameworkElement fe && fe.Parent is Panel p)
|
||||||
|
p.Children.Remove(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hit-test a live text-edit handle at the given canvas point; returns its corner tag or null.
|
||||||
|
private string? TextEditHandleAt(Point pos)
|
||||||
|
{
|
||||||
|
foreach (var hd in _textEditHandles)
|
||||||
|
{
|
||||||
|
double hx = Canvas.GetLeft(hd), hy = Canvas.GetTop(hd);
|
||||||
|
if (pos.X >= hx && pos.X <= hx + hd.Width &&
|
||||||
|
pos.Y >= hy && pos.Y <= hy + hd.Height)
|
||||||
|
return hd.Tag as string ?? "SE";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attached as PreviewKeyDown (tunneling) so Enter is caught before the TextBox inserts a line
|
||||||
|
// break: Enter commits, Shift+Enter falls through to make a newline (the box is AcceptsReturn).
|
||||||
|
private void TextBox_PreviewKeyDown(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == Key.Escape)
|
||||||
|
{
|
||||||
|
CancelActiveTextEdit();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
else if (e.Key == Key.Z && (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control
|
||||||
|
&& sender is TextBox ztb && !ztb.CanUndo)
|
||||||
|
{
|
||||||
|
// The box has no typed text left to undo, so Ctrl+Z backs out of the whole in-place edit
|
||||||
|
// (same as Escape) instead of being a no-op - otherwise a fresh edit could only be undone
|
||||||
|
// after committing it. While the box still has edits to undo, WPF's TextBox handles Ctrl+Z.
|
||||||
|
CancelActiveTextEdit();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
else if (e.Key == Key.Enter && Keyboard.Modifiers != ModifierKeys.Shift)
|
||||||
|
{
|
||||||
|
CommitActiveTextBox();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
else if (Keyboard.Modifiers == ModifierKeys.Control
|
||||||
|
&& (e.Key == Key.B || e.Key == Key.I || e.Key == Key.U))
|
||||||
|
{
|
||||||
|
// 1.6.6: the standard formatting chords while typing in a box - mirror the text
|
||||||
|
// bar's B/I/U toggles (whole-annotation style, like the buttons). Ctrl+I became
|
||||||
|
// available when Invert moved to the bare N key.
|
||||||
|
if (e.Key == Key.B) _textBold = !_textBold;
|
||||||
|
else if (e.Key == Key.I) _textItalic = !_textItalic;
|
||||||
|
else _textUnderline = !_textUnderline;
|
||||||
|
if (sender is TextBox stb) StyleEditBox(stb);
|
||||||
|
ApplyTextStyleToSelection();
|
||||||
|
ShowTextSettings();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abandons the in-progress text edit: removes the editing box and its handles, drops a pending
|
||||||
|
// cover (placed un-undone), and restores a re-edited annotation. Shared by Escape and the
|
||||||
|
// "Ctrl+Z with nothing left in the box" path.
|
||||||
|
private void CancelActiveTextEdit()
|
||||||
|
{
|
||||||
|
RemoveTextEditHandles();
|
||||||
|
RemoveReeditCoverOutline(); // edit canceled; drop the cover hint (repaint follows)
|
||||||
|
if (_activeTextBox is not null)
|
||||||
|
{
|
||||||
|
RemoveFromParent(_activeTextBox);
|
||||||
|
_activeTextBox = null;
|
||||||
|
}
|
||||||
|
// Canceling an existing-text edit drops the cover too (it was placed un-undone).
|
||||||
|
if (_pendingCover is not null) DiscardPendingCover();
|
||||||
|
if (_reeditOriginal is not null)
|
||||||
|
{
|
||||||
|
int rp = _reeditOriginal.PageIndex;
|
||||||
|
if (!_annotations.TryGetValue(rp, out var rlist)) { rlist = []; _annotations[rp] = rlist; }
|
||||||
|
rlist.Add(_reeditOriginal);
|
||||||
|
_reeditOriginal = null;
|
||||||
|
RenderAllAnnotations(rp);
|
||||||
|
}
|
||||||
|
if (_currentTool != EditTool.Text) HideTextSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TextBox_LostFocus(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
// Don't commit while a resize handle is being dragged (the box temporarily loses focus).
|
||||||
|
if (_draggingTextEditHandle) return;
|
||||||
|
// Commit if the box has content, or (for an existing-text edit) even when emptied, so the
|
||||||
|
// pending cover is resolved instead of lingering when the user clicks away from a blank edit.
|
||||||
|
if (_activeTextBox is not null && (!string.IsNullOrWhiteSpace(_activeTextBox.Text) || _pendingCover is not null))
|
||||||
|
{
|
||||||
|
Dispatcher.BeginInvoke(new Action(() =>
|
||||||
|
{
|
||||||
|
// Keep the edit box open if focus moved into the size/color bar so the
|
||||||
|
// user can restyle (the Size ComboBox takes focus; color swatches do not).
|
||||||
|
if (_textSettingsBar is not null && Keyboard.FocusedElement is DependencyObject fe
|
||||||
|
&& IsDescendantOf(fe, _textSettingsBar))
|
||||||
|
return;
|
||||||
|
CommitActiveTextBox();
|
||||||
|
}),
|
||||||
|
System.Windows.Threading.DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CommitActiveTextBox()
|
||||||
|
{
|
||||||
|
if (_activeTextBox is null) return;
|
||||||
|
var tb = _activeTextBox;
|
||||||
|
_activeTextBox = null;
|
||||||
|
RemoveTextEditHandles();
|
||||||
|
RemoveReeditCoverOutline(); // the re-edit is ending; drop its cover hint (repaint follows)
|
||||||
|
string reeditPair = _reeditOriginal?.PairId ?? ""; // preserve a re-edited text's cover pairing
|
||||||
|
_reeditOriginal = null; // committing replaces any annotation being re-edited
|
||||||
|
|
||||||
|
string content = tb.Text.Trim();
|
||||||
|
int pageIdx = tb.Tag is int idx ? idx : _currentPage;
|
||||||
|
double x = Canvas.GetLeft(tb);
|
||||||
|
double y = Canvas.GetTop(tb);
|
||||||
|
|
||||||
|
// Remove the editing box from whatever canvas actually parents it. _activeCanvas may have
|
||||||
|
// moved to another page when the user clicked away to commit (continuous/grid), and a
|
||||||
|
// re-looked-up page canvas can be a different instance than the one the box was placed on,
|
||||||
|
// either of which leaves the box orphaned (visible, but immune to Delete/Clear All). Its
|
||||||
|
// live Parent is the correct host.
|
||||||
|
RemoveFromParent(tb);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(content))
|
||||||
|
{
|
||||||
|
double boxW = (!double.IsNaN(tb.Width) && tb.Width > 0) ? tb.Width
|
||||||
|
: (tb.ActualWidth > 0 ? tb.ActualWidth : TextBoxDefaultWidth);
|
||||||
|
var ta = new TextAnnotation
|
||||||
|
{
|
||||||
|
PageIndex = pageIdx,
|
||||||
|
Position = new Point(x, y),
|
||||||
|
Content = content,
|
||||||
|
FontSize = tb.FontSize,
|
||||||
|
FontName = _textFontName,
|
||||||
|
Bold = _textBold,
|
||||||
|
Italic = _textItalic,
|
||||||
|
Strike = _textStrike,
|
||||||
|
Underline = _textUnderline,
|
||||||
|
Width = boxW
|
||||||
|
};
|
||||||
|
ta.SetColor(tb.Foreground is SolidColorBrush scb ? scb.Color : Colors.Black);
|
||||||
|
// A cover-paired edit gets no fill of its own - the opaque cover behind it is the backdrop.
|
||||||
|
ta.SetFill(_pendingCover is not null ? Colors.Transparent : _textFillColor);
|
||||||
|
// Free-form height if the box was manually resized; otherwise fit to the wrapped text.
|
||||||
|
ta.Height = (!double.IsNaN(tb.Height) && tb.Height > 0)
|
||||||
|
? tb.Height
|
||||||
|
: MeasureTextBoxHeight(content, boxW, tb.FontSize);
|
||||||
|
// Keep the placed box fully on-page so its corners (and resize handles) stay reachable.
|
||||||
|
ta.Position = ClampRectToPage(pageIdx, new Rect(ta.Position, new Size(ta.Width, ta.Height))).Location;
|
||||||
|
// Carry the pairing so the cover knows its partner text exists (renders dashed).
|
||||||
|
ta.PairId = _pendingCover is not null ? _pendingCover.PairId : reeditPair;
|
||||||
|
if (_pendingCover is not null)
|
||||||
|
{
|
||||||
|
// Existing-text edit: the cover is already in _annotations. Add the text beside it and
|
||||||
|
// push ONE grouped undo so a single Ctrl+Z right after cancels the whole edit. After
|
||||||
|
// this, cover and text are independent annotations (move/resize/recolor separately).
|
||||||
|
_annotations[pageIdx].Add(ta);
|
||||||
|
PushUndo(new UndoEntry(UndoKind.AnnotationGroup, pageIdx,
|
||||||
|
WasDirty: _pendingEditWasDirty, AnnotGroup: [_pendingCover, ta]));
|
||||||
|
_pendingCover = null;
|
||||||
|
MarkDirty();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AddAnnotation(ta);
|
||||||
|
}
|
||||||
|
RenderAllAnnotations(pageIdx); // redraw on the correct page's canvas
|
||||||
|
WarnIfGlyphsWillBeLost(ta); // #168: say so NOW, not after saving and reopening
|
||||||
|
}
|
||||||
|
else if (_pendingCover is not null)
|
||||||
|
{
|
||||||
|
// Edit left empty - abandon it and drop the cover (added without its own undo entry).
|
||||||
|
DiscardPendingCover();
|
||||||
|
}
|
||||||
|
if (_currentTool != EditTool.Text) HideTextSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #168: the editor borrows glyphs from any installed font, so text ALWAYS looks right while
|
||||||
|
/// being typed - but a save can only embed fonts, and a character no installed font carries
|
||||||
|
/// becomes a box in the file. That used to be invisible until the user saved, closed and
|
||||||
|
/// reopened. Now it is said at the moment the text is placed, while it can still be fixed.
|
||||||
|
///
|
||||||
|
/// Only fires when the whole fallback chain comes up short (a box mixing two non-Latin
|
||||||
|
/// scripts, or a script with no font installed at all), so it does not nag: ordinary
|
||||||
|
/// Japanese, Chinese, Korean or Bengali text resolves silently.
|
||||||
|
/// </summary>
|
||||||
|
private void WarnIfGlyphsWillBeLost(TextAnnotation ta)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(ta.Content)) return;
|
||||||
|
string want = string.IsNullOrEmpty(ta.FontName) ? "Segoe UI" : ta.FontName;
|
||||||
|
string family = Services.FontCoverage.PickFamily(want, ta.Content);
|
||||||
|
string missing = Services.FontCoverage.UncoveredChars(family, ta.Content);
|
||||||
|
if (missing.Length == 0) return;
|
||||||
|
KillerDialog.Show(Host!.Window, string.Format(Loc("Str_Font_NoGlyphs"), missing), "KillerPDF",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
catch { /* the warning must never be the thing that breaks placing text */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the not-yet-committed cover when an existing-text edit is canceled or left empty. The
|
||||||
|
// cover was added straight to _annotations without an undo entry, so just drop it and repaint.
|
||||||
|
private void DiscardPendingCover()
|
||||||
|
{
|
||||||
|
if (_pendingCover is null) return;
|
||||||
|
int pg = _pendingCover.PageIndex;
|
||||||
|
if (_annotations.TryGetValue(pg, out var list)) list.Remove(_pendingCover);
|
||||||
|
_pendingCover = null;
|
||||||
|
MarkDirty(_pendingEditWasDirty);
|
||||||
|
RenderAllAnnotations(pg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cover background sampling ───────────────────────────────────────────────
|
||||||
|
// Reads the page background color around an existing-text line so a cover blends into colored
|
||||||
|
// headers/panels instead of showing a white box. Best-effort: returns white on any failure.
|
||||||
|
|
||||||
|
// The page's rendered bitmap: the Image sibling of its overlay canvas (continuous/grid/two-page),
|
||||||
|
// or the single-view PageImage. View-mode independent so sampling works everywhere.
|
||||||
|
private System.Windows.Media.Imaging.BitmapSource? PageBitmapFor(int pageIdx)
|
||||||
|
{
|
||||||
|
if (_continuousCanvases.TryGetValue(pageIdx, out var overlay) && overlay.Parent is Panel mp)
|
||||||
|
foreach (var ch in mp.Children)
|
||||||
|
if (ch is Image im && im.Source is System.Windows.Media.Imaging.BitmapSource bs) return bs;
|
||||||
|
if (pageIdx == _currentPage && PageImage.Source is System.Windows.Media.Imaging.BitmapSource pbs)
|
||||||
|
return pbs;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color ReadBgraPixel(System.Windows.Media.Imaging.BitmapSource bmp, int x, int y)
|
||||||
|
{
|
||||||
|
x = Math.Max(0, Math.Min(x, bmp.PixelWidth - 1));
|
||||||
|
y = Math.Max(0, Math.Min(y, bmp.PixelHeight - 1));
|
||||||
|
var px = new byte[4];
|
||||||
|
bmp.CopyPixels(new Int32Rect(x, y, 1, 1), px, 4, 0); // Bgra32: B,G,R,A
|
||||||
|
// Composite over white (the PDF page background) so transparent pixels - common on repaired
|
||||||
|
// or scanned renders - read as white, not black. Returns an opaque color for sampling.
|
||||||
|
double a = px[3] / 255.0;
|
||||||
|
byte r = (byte)(px[2] * a + 255 * (1 - a));
|
||||||
|
byte g = (byte)(px[1] * a + 255 * (1 - a));
|
||||||
|
byte b = (byte)(px[0] * a + 255 * (1 - a));
|
||||||
|
return Color.FromRgb(r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Background color around a text line, in canvas (render-dim) coordinates. White on failure.</summary>
|
||||||
|
private Color SampleCoverColor(int pageIdx, Rect textBounds)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bmp = PageBitmapFor(pageIdx);
|
||||||
|
if (bmp is null || !_renderDims.TryGetValue(pageIdx, out var rd) || rd.w <= 0 || rd.h <= 0)
|
||||||
|
return Colors.White;
|
||||||
|
double sx = bmp.PixelWidth / (double)rd.w; // render-dim -> bitmap pixels
|
||||||
|
double sy = bmp.PixelHeight / (double)rd.h;
|
||||||
|
// Sample the whitespace just above and below the line (usually pure background) at a few
|
||||||
|
// x offsets; take the median by luminance to shrug off a stray glyph or anti-aliased edge.
|
||||||
|
double gap = Math.Max(3.0, textBounds.Height * 0.4);
|
||||||
|
var cols = new List<Color>();
|
||||||
|
foreach (double f in new[] { 0.2, 0.5, 0.8 })
|
||||||
|
{
|
||||||
|
double x = textBounds.Left + textBounds.Width * f;
|
||||||
|
cols.Add(ReadBgraPixel(bmp, (int)Math.Round(x * sx), (int)Math.Round((textBounds.Top - gap) * sy)));
|
||||||
|
cols.Add(ReadBgraPixel(bmp, (int)Math.Round(x * sx), (int)Math.Round((textBounds.Bottom + gap) * sy)));
|
||||||
|
}
|
||||||
|
if (cols.Count == 0) return Colors.White;
|
||||||
|
cols.Sort((a, b) => (0.299 * a.R + 0.587 * a.G + 0.114 * a.B)
|
||||||
|
.CompareTo(0.299 * b.R + 0.587 * b.G + 0.114 * b.B));
|
||||||
|
return cols[cols.Count / 2];
|
||||||
|
}
|
||||||
|
catch { return Colors.White; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double ColorDist(Color a, Color b)
|
||||||
|
{
|
||||||
|
double dr = a.R - b.R, dg = a.G - b.G, db = a.B - b.B;
|
||||||
|
return Math.Sqrt(dr * dr + dg * dg + db * db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The text "ink" color of a line: the color inside the glyph box farthest from the
|
||||||
|
/// page background. Averages the purest-ink samples so anti-aliased edges don't desaturate it.
|
||||||
|
/// Black on failure or when no real contrast is found.</summary>
|
||||||
|
private Color SampleTextColor(int pageIdx, Rect textBounds, Color bg)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bmp = PageBitmapFor(pageIdx);
|
||||||
|
if (bmp is null || !_renderDims.TryGetValue(pageIdx, out var rd) || rd.w <= 0 || rd.h <= 0)
|
||||||
|
return Colors.Black;
|
||||||
|
double sx = bmp.PixelWidth / (double)rd.w;
|
||||||
|
double sy = bmp.PixelHeight / (double)rd.h;
|
||||||
|
int cols = 16, rows = Math.Max(3, (int)Math.Min(8, textBounds.Height / 3));
|
||||||
|
var scored = new List<(double dist, Color c)>();
|
||||||
|
for (int ix = 0; ix < cols; ix++)
|
||||||
|
for (int iy = 0; iy < rows; iy++)
|
||||||
|
{
|
||||||
|
double x = textBounds.Left + textBounds.Width * (ix + 0.5) / cols;
|
||||||
|
double y = textBounds.Top + textBounds.Height * (iy + 0.5) / rows;
|
||||||
|
var c = ReadBgraPixel(bmp, (int)Math.Round(x * sx), (int)Math.Round(y * sy));
|
||||||
|
scored.Add((ColorDist(c, bg), c));
|
||||||
|
}
|
||||||
|
if (scored.Count == 0) return Colors.Black;
|
||||||
|
scored.Sort((a, b) => b.dist.CompareTo(a.dist)); // most ink-like first
|
||||||
|
double maxDist = scored[0].dist;
|
||||||
|
if (maxDist < 24) return Colors.Black; // no real contrast -> default
|
||||||
|
double thresh = maxDist * 0.7; // purest-ink cluster only
|
||||||
|
double r = 0, g = 0, bl = 0; int n = 0;
|
||||||
|
foreach (var (dist, c) in scored) { if (dist < thresh) break; r += c.R; g += c.G; bl += c.B; n++; }
|
||||||
|
return n == 0 ? Colors.Black : Color.FromRgb((byte)(r / n), (byte)(g / n), (byte)(bl / n));
|
||||||
|
}
|
||||||
|
catch { return Colors.Black; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,359 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Animation;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using System.Windows.Shapes;
|
||||||
|
using Docnet.Core;
|
||||||
|
using Docnet.Core.Models;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using PdfSharpCore.Drawing;
|
||||||
|
using PdfSharpCore.Pdf;
|
||||||
|
using PdfSharpCore.Pdf.IO;
|
||||||
|
using KillerPDF.Services;
|
||||||
|
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
|
||||||
|
|
||||||
|
namespace KillerPDF.Controls
|
||||||
|
{
|
||||||
|
// Wheel zoom, wheel scroll, and the pointer gestures that start on the page surface.
|
||||||
|
//
|
||||||
|
// Moved from Shell/Zoom.cs; this namespace and class line are the only changes. It lives with
|
||||||
|
// the render pipeline because the two share the zoom state and the gesture routing
|
||||||
|
// (_activeCanvas / _gestureCanvas) that decides which page a press landed on.
|
||||||
|
//
|
||||||
|
// Window members referenced bare here resolve through PdfViewer.Bridge.cs.
|
||||||
|
public partial class PdfViewer
|
||||||
|
{
|
||||||
|
private readonly WheelPageFlipGate _wheelPageFlipGate = new();
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Zoom
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// internal: PdfViewer's XAML binds this and forwards to it.
|
||||||
|
internal void PagePreview_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
|
||||||
|
{
|
||||||
|
// #209: match the standard Windows/browser gesture and reuse the same path as a
|
||||||
|
// physical tilt wheel. Wheel-down moves right; wheel-up moves left.
|
||||||
|
if (Keyboard.Modifiers == ModifierKeys.Shift)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
ScrollHorizontalExt(-e.Delta);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Keyboard.Modifiers == ModifierKeys.Control)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
if (_viewMode == ViewMode.Grid) { GridZoomStep(e.Delta < 0); return; }
|
||||||
|
|
||||||
|
// Capture cursor position and scroll offsets BEFORE zoom changes so we can
|
||||||
|
// compute the new offsets that keep the point under the cursor stationary.
|
||||||
|
Point cursorInViewport = e.GetPosition(PagePreviewPanel);
|
||||||
|
double oldZoom = _zoomLevel;
|
||||||
|
double oldHOff = PagePreviewPanel.HorizontalOffset;
|
||||||
|
double oldVOff = PagePreviewPanel.VerticalOffset;
|
||||||
|
|
||||||
|
// Smooth wheel zoom. Two parts:
|
||||||
|
// 1) A multiplicative step - every notch changes the zoom by the same RATIO. The
|
||||||
|
// old additive ZoomStep was a ~50% jump when zoomed out and barely visible when
|
||||||
|
// zoomed in. The exponent scales with e.Delta, so a precision touchpad's small
|
||||||
|
// frequent deltas produce proportionally small ratios (a continuous glide).
|
||||||
|
// 2) A lite apply - only the ScaleTransform moves during the gesture (instant,
|
||||||
|
// flicker-free, same path as live window-resize); the expensive tile/link
|
||||||
|
// refresh and hi-res re-sharpen run ONCE when the wheel rests (settle timer)
|
||||||
|
// instead of on every notch, which is what made zooming feel steppy.
|
||||||
|
_fitMode = FitMode.None;
|
||||||
|
_zoomLevel = Math.Max(ZoomMin, Math.Min(ZoomMax,
|
||||||
|
_zoomLevel * Math.Pow(WheelZoomFactor, e.Delta / 120.0)));
|
||||||
|
ApplyZoom(lite: true);
|
||||||
|
StartZoomSettleTimer();
|
||||||
|
|
||||||
|
// After layout settles, reposition the scroll so the cursor point stays fixed.
|
||||||
|
// Formula: newOffset = (oldOffset + cursorPos) * (newZoom / oldZoom) - cursorPos
|
||||||
|
double ratio = _zoomLevel / oldZoom;
|
||||||
|
double newHOff = (oldHOff + cursorInViewport.X) * ratio - cursorInViewport.X;
|
||||||
|
double newVOff = (oldVOff + cursorInViewport.Y) * ratio - cursorInViewport.Y;
|
||||||
|
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)(() =>
|
||||||
|
{
|
||||||
|
PagePreviewPanel.ScrollToHorizontalOffset(Math.Max(0, newHOff));
|
||||||
|
PagePreviewPanel.ScrollToVerticalOffset(Math.Max(0, newVOff));
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular scroll. Grid and Continuous are a single scroll over the WHOLE document, so the
|
||||||
|
// wheel must never be hijacked for page navigation there - it always scrolls. (Page-nav
|
||||||
|
// hijacking here was the old grid-refuses-to-scroll bug: right after a zoom/column change
|
||||||
|
// the extent can momentarily measure as zero and the nav fallback fired instead.)
|
||||||
|
if (_viewMode == ViewMode.Grid || _viewMode == ViewMode.Continuous)
|
||||||
|
{
|
||||||
|
ScrollWheel(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single / Two-Page: a page often fits the viewport, so at the scroll boundary fall
|
||||||
|
// through to page navigation so the user can reach adjacent pages without the sidebar.
|
||||||
|
if (PagePreviewPanel.ScrollableHeight <= 0)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
if (_wheelPageFlipGate.TryConfirm(e.Delta, DateTime.UtcNow))
|
||||||
|
NavigatePageByWheel(e.Delta);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool atTop = PagePreviewPanel.VerticalOffset <= 0;
|
||||||
|
bool atBottom = PagePreviewPanel.VerticalOffset >= PagePreviewPanel.ScrollableHeight - 1;
|
||||||
|
if ((atTop && e.Delta > 0) || (atBottom && e.Delta < 0))
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
if (_wheelPageFlipGate.TryConfirm(e.Delta, DateTime.UtcNow))
|
||||||
|
NavigatePageByWheel(e.Delta);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_wheelPageFlipGate.NoteContentScroll(DateTime.UtcNow);
|
||||||
|
ScrollWheel(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zoom ratio per full wheel notch (e.Delta = 120) for Ctrl+scroll. 1.1 lands close to the
|
||||||
|
// old additive step at 100% zoom but stays a constant 10% everywhere on the range.
|
||||||
|
private const double WheelZoomFactor = 1.1;
|
||||||
|
|
||||||
|
// Wheel over the toolbar zoom dropdown: same multiplicative step as Ctrl+scroll, without
|
||||||
|
// the cursor anchoring (the cursor is on the toolbar, not the page). Handled is set so the
|
||||||
|
// ComboBox does not cycle its preset items under the wheel.
|
||||||
|
internal void ZoomBoxWheel(MouseWheelEventArgs e)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
if (_doc is null) return;
|
||||||
|
if (_viewMode == ViewMode.Grid) { GridZoomStep(e.Delta < 0); return; }
|
||||||
|
_fitMode = FitMode.None;
|
||||||
|
_zoomLevel = Math.Max(ZoomMin, Math.Min(ZoomMax,
|
||||||
|
_zoomLevel * Math.Pow(WheelZoomFactor, e.Delta / 120.0)));
|
||||||
|
ApplyZoom(lite: true); // SyncZoomBox inside keeps the shown % live per notch
|
||||||
|
StartZoomSettleTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounced full zoom apply, shared by every Ctrl+scroll notch: while the wheel is moving
|
||||||
|
// only the lite ScaleTransform runs; once it rests for a beat, do the one full ApplyZoom
|
||||||
|
// (tile/link refresh, and the hi-res re-sharpen it queues) plus the status-bar update that
|
||||||
|
// SetZoom would have shown per notch.
|
||||||
|
private System.Windows.Threading.DispatcherTimer? _zoomSettleTimer;
|
||||||
|
|
||||||
|
private void StartZoomSettleTimer()
|
||||||
|
{
|
||||||
|
if (_zoomSettleTimer is null)
|
||||||
|
{
|
||||||
|
_zoomSettleTimer = new System.Windows.Threading.DispatcherTimer
|
||||||
|
{ Interval = TimeSpan.FromMilliseconds(200) };
|
||||||
|
_zoomSettleTimer.Tick += (_, _) =>
|
||||||
|
{
|
||||||
|
_zoomSettleTimer!.Stop();
|
||||||
|
if (_doc is null) return;
|
||||||
|
ApplyZoom();
|
||||||
|
if (_currentPage >= 0)
|
||||||
|
SetStatus(string.Format(Loc("Str_PageOf"), _currentPage + 1, _doc.PageCount) + $" - {DisplayZoomPct():F0}%");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_zoomSettleTimer.Stop();
|
||||||
|
_zoomSettleTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ScrollViewer default (3 lines = 48 DIP per wheel notch) feels slow on tall documents,
|
||||||
|
// so scroll WheelScrollFactor times that instead. e.Delta is +-120 per notch on a standard
|
||||||
|
// wheel (precision touchpads send smaller, more frequent deltas, which scale the same way).
|
||||||
|
// ScrollToVerticalOffset clamps to the valid range itself.
|
||||||
|
// internal: PageSelection.cs reuses it so the sidebar scrolls at the document's speed.
|
||||||
|
internal const double WheelScrollFactor = 3.0;
|
||||||
|
|
||||||
|
private void ScrollWheel(MouseWheelEventArgs e)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
PagePreviewPanel.ScrollToVerticalOffset(
|
||||||
|
PagePreviewPanel.VerticalOffset - e.Delta * (48.0 / 120.0) * WheelScrollFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #196: horizontal scroll fed from the window's WM_MOUSEHWHEEL hook (WPF surfaces no
|
||||||
|
// event for it). Same per-delta distance as the vertical wheel; positive = right.
|
||||||
|
internal void ScrollHorizontalExt(int delta)
|
||||||
|
{
|
||||||
|
if (_doc is null || PagePreviewPanel.Visibility != Visibility.Visible) return;
|
||||||
|
PagePreviewPanel.ScrollToHorizontalOffset(
|
||||||
|
PagePreviewPanel.HorizontalOffset + delta * (48.0 / 120.0) * WheelScrollFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walks up the visual tree from the press's hit element to see if it landed on the scrollbar
|
||||||
|
// (thumb, track, or repeat buttons). Used to exempt scrollbar presses from pane pan/marquee/crop.
|
||||||
|
private static bool PressIsOnScrollBar(MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
DependencyObject? d = e.OriginalSource as DependencyObject;
|
||||||
|
while (d is not null)
|
||||||
|
{
|
||||||
|
if (d is System.Windows.Controls.Primitives.ScrollBar) return true;
|
||||||
|
d = d is System.Windows.Media.Visual or System.Windows.Media.Media3D.Visual3D
|
||||||
|
? System.Windows.Media.VisualTreeHelper.GetParent(d)
|
||||||
|
: LogicalTreeHelper.GetParent(d);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void PagePreviewPanel_PreviewMouseDown(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
// A press that lands on the document scrollbar must reach the scrollbar itself (thumb drag,
|
||||||
|
// track paging). The pan/crop/marquee handling below otherwise claims the press first and sets
|
||||||
|
// e.Handled, so the thumb could never be grabbed. Let scrollbar presses fall through untouched.
|
||||||
|
if (PressIsOnScrollBar(e)) return;
|
||||||
|
|
||||||
|
bool spaceDown = Keyboard.IsKeyDown(Key.Space);
|
||||||
|
if (e.ChangedButton == MouseButton.Middle ||
|
||||||
|
(e.ChangedButton == MouseButton.Left && spaceDown))
|
||||||
|
{
|
||||||
|
_isPanning = true;
|
||||||
|
_panStart = e.GetPosition(PagePreviewPanel);
|
||||||
|
_panScrollH = PagePreviewPanel.HorizontalOffset;
|
||||||
|
_panScrollV = PagePreviewPanel.VerticalOffset;
|
||||||
|
PagePreviewPanel.CaptureMouse();
|
||||||
|
PagePreviewPanel.Cursor = Cursors.SizeAll;
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
// Crop: allow starting the selection OUTSIDE the page - catch margin clicks, route them to the
|
||||||
|
// nearest page overlay, and clamp the start to the page edge so the crop rect stays on the page.
|
||||||
|
else if (e.ChangedButton == MouseButton.Left && !spaceDown
|
||||||
|
&& _currentTool == EditTool.Crop && _doc is not null)
|
||||||
|
{
|
||||||
|
Canvas? target = ResolveMarginOverlay(e);
|
||||||
|
if (target is not null && target.Width > 0 && target.Height > 0)
|
||||||
|
{
|
||||||
|
_activeCanvas = target;
|
||||||
|
// Pin the gesture surface/page so mouse-move/up resolve against this overlay
|
||||||
|
// (a margin crop start doesn't go through Canvas_MouseLeftButtonDown).
|
||||||
|
_gestureCanvas = target;
|
||||||
|
_gesturePage = target.Tag is int gt ? gt : _currentPage;
|
||||||
|
var p = e.GetPosition(target);
|
||||||
|
p.X = Math.Max(0, Math.Min(target.Width, p.X));
|
||||||
|
p.Y = Math.Max(0, Math.Min(target.Height, p.Y));
|
||||||
|
StartCropDraw(p);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Marquee select: start a selection rectangle in the margin so it can span onto the pages. Same
|
||||||
|
// routing as crop, but the start point is NOT clamped, so the box can begin off-page.
|
||||||
|
else if (e.ChangedButton == MouseButton.Left && !spaceDown
|
||||||
|
&& _currentTool == EditTool.Select && _doc is not null)
|
||||||
|
{
|
||||||
|
Canvas? target = ResolveMarginOverlay(e);
|
||||||
|
if (target is not null && target.Width > 0 && target.Height > 0)
|
||||||
|
{
|
||||||
|
StartMarqueeDraw(target, e.GetPosition(target));
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves which page overlay a margin (off-page) click attaches to, or null when the click is
|
||||||
|
// actually on a page (left to that page's own surface). Shared by off-page crop and marquee starts.
|
||||||
|
private Canvas? ResolveMarginOverlay(MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (_viewMode == ViewMode.Continuous)
|
||||||
|
{
|
||||||
|
if (e.OriginalSource is DependencyObject osc && IsWithinPageOverlay(osc)) return null;
|
||||||
|
int pg = _currentPage;
|
||||||
|
if (pg < 0 || !_continuousCanvases.ContainsKey(pg))
|
||||||
|
pg = NearestContinuousPage(e.GetPosition(_continuousPanel).Y);
|
||||||
|
return pg >= 0 && _continuousCanvases.TryGetValue(pg, out var c) ? c : null;
|
||||||
|
}
|
||||||
|
bool onPrimary = e.OriginalSource is DependencyObject oss && IsDescendantOf(oss, _annotationCanvas);
|
||||||
|
bool onTile = e.OriginalSource is DependencyObject ost && IsWithinPageOverlay(ost);
|
||||||
|
return (!onPrimary && !onTile) ? _annotationCanvas : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begins a marquee anchored to refCanvas at posInRef (that page's coords, possibly off-page and
|
||||||
|
// un-clamped). The box draws on the cross-page MarqueeLayer; the existing move/up handlers finish it.
|
||||||
|
private void StartMarqueeDraw(Canvas refCanvas, Point posInRef)
|
||||||
|
{
|
||||||
|
_activeCanvas = refCanvas;
|
||||||
|
_gestureCanvas = refCanvas;
|
||||||
|
_gesturePage = refCanvas.Tag is int gt ? gt : _currentPage;
|
||||||
|
ClearSelection();
|
||||||
|
ClearTextSelection();
|
||||||
|
_isSelecting = true;
|
||||||
|
_selectStart = posInRef;
|
||||||
|
_selectRect = new Rectangle
|
||||||
|
{
|
||||||
|
Fill = AccentBrush(40),
|
||||||
|
Stroke = AccentBrush(150),
|
||||||
|
StrokeThickness = 1,
|
||||||
|
Width = 0, Height = 0,
|
||||||
|
IsHitTestVisible = false
|
||||||
|
};
|
||||||
|
MarqueeLayer.Children.Add(_selectRect);
|
||||||
|
UpdateMarquee(posInRef, posInRef);
|
||||||
|
refCanvas.CaptureMouse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begin a crop selection on the active overlay at pos (render-dim coords).
|
||||||
|
private void StartCropDraw(Point pos)
|
||||||
|
{
|
||||||
|
_cropPageIndex = _activeCanvas.Tag is int cpi ? cpi : (_viewMode == ViewMode.Grid ? 0 : _currentPage);
|
||||||
|
ClearSelection();
|
||||||
|
_isDrawing = true;
|
||||||
|
_drawStart = pos;
|
||||||
|
// Draw the NEW box as a separate rect; the existing box, handles, and bar stay put until this
|
||||||
|
// draw is committed on mouse-up (so a mouse-down never wipes the current box or bar).
|
||||||
|
var cropDrawRect = new Rectangle
|
||||||
|
{
|
||||||
|
Stroke = Brushes.White,
|
||||||
|
StrokeThickness = 1.5,
|
||||||
|
StrokeDashArray = [5, 3],
|
||||||
|
Fill = AccentBrush(55),
|
||||||
|
Width = 0,
|
||||||
|
Height = 0,
|
||||||
|
IsHitTestVisible = false,
|
||||||
|
Effect = new System.Windows.Media.Effects.DropShadowEffect
|
||||||
|
{ Color = Colors.Black, ShadowDepth = 0, BlurRadius = 3, Opacity = 0.7 },
|
||||||
|
};
|
||||||
|
Canvas.SetLeft(cropDrawRect, pos.X);
|
||||||
|
Canvas.SetTop(cropDrawRect, pos.Y);
|
||||||
|
Panel.SetZIndex(cropDrawRect, 2);
|
||||||
|
_activeCanvas.Children.Add(cropDrawRect);
|
||||||
|
_activePreview = cropDrawRect;
|
||||||
|
_activeCanvas.CaptureMouse();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsWithinPageOverlay(DependencyObject node)
|
||||||
|
{
|
||||||
|
var cur = node;
|
||||||
|
while (cur != null)
|
||||||
|
{
|
||||||
|
if (cur is Canvas c && _continuousCanvases.ContainsValue(c)) return true;
|
||||||
|
cur = VisualTreeHelper.GetParent(cur);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void PagePreviewPanel_PreviewMouseMove(object sender, MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_isPanning) return;
|
||||||
|
var pos = e.GetPosition(PagePreviewPanel);
|
||||||
|
PagePreviewPanel.ScrollToHorizontalOffset(_panScrollH - (pos.X - _panStart.X));
|
||||||
|
PagePreviewPanel.ScrollToVerticalOffset(_panScrollV - (pos.Y - _panStart.Y));
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void PagePreviewPanel_PreviewMouseUp(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_isPanning) return;
|
||||||
|
if (e.ChangedButton != MouseButton.Middle && e.ChangedButton != MouseButton.Left) return;
|
||||||
|
_isPanning = false;
|
||||||
|
PagePreviewPanel.ReleaseMouseCapture();
|
||||||
|
PagePreviewPanel.Cursor = _spaceHeld ? Cursors.Hand : Cursors.Arrow;
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,594 @@
|
|||||||
|
<UserControl x:Class="KillerPDF.Controls.PdfViewer"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<!-- One document view: this pane's tab strip and its card.
|
||||||
|
|
||||||
|
The card's MARGIN lives on this control, not on the borders below - ApplySidebarSide sets
|
||||||
|
it to flip the 8px gutter to whichever side the document is on. -->
|
||||||
|
<Grid>
|
||||||
|
<!-- Row 0 is the strip, row 1 the card. Each pane carrying its own strip means the strip
|
||||||
|
tracks its pane's width and position for free, including through a boundary drag.
|
||||||
|
Row 0 is Auto and the band collapses below two tabs. -->
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- ZIndex above the card: the card's -1 top margin tucks its top border into this band's
|
||||||
|
row, and the active tab - which carries the pane's own BgCanvas - covers that pixel so
|
||||||
|
the two read as one surface. -->
|
||||||
|
<Border Grid.Row="0" x:Name="TabStripBorder" Panel.ZIndex="10"
|
||||||
|
Height="{DynamicResource TabBandHeight}"
|
||||||
|
Background="Transparent" Visibility="Collapsed"
|
||||||
|
SizeChanged="TabStripBorder_SizeChanged">
|
||||||
|
<Grid>
|
||||||
|
<!-- Match KillerShell: the strip is transparent and does not paint another grain
|
||||||
|
tile over the window's existing background. Only the opaque active tab
|
||||||
|
replaces that shared grain below. -->
|
||||||
|
<Border x:Name="TabStripFade" IsHitTestVisible="False"
|
||||||
|
Opacity="{DynamicResource BarShadowOpacity}">
|
||||||
|
<Border.Background>
|
||||||
|
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
|
||||||
|
<GradientStop Color="#00000000" Offset="0"/>
|
||||||
|
<GradientStop Color="#00000000" Offset="0.45"/>
|
||||||
|
<GradientStop Color="#59000000" Offset="1"/>
|
||||||
|
</LinearGradientBrush>
|
||||||
|
</Border.Background>
|
||||||
|
</Border>
|
||||||
|
<!-- TabBarRing, ported from KillerShell. The card's top border drawn again, radii
|
||||||
|
and all - 7px tall with a -6 bottom margin, so its 1px top edge lands in the
|
||||||
|
band's last row and its curved sides drop out of the band onto the card's own
|
||||||
|
left/right border. A flat 1px strip would run past the card's rounded corners.
|
||||||
|
SyncPaneLeadingCorner keeps the radii in step with the card's; SetFocusHalo
|
||||||
|
drives its brush alongside the card's.
|
||||||
|
Declared after the fade (whose dark bottom would wash it out) and before the
|
||||||
|
tabs, so the active tab covers its own segment and breaks the line at the tab. -->
|
||||||
|
<Border x:Name="TabBarRing" Height="7" VerticalAlignment="Bottom" Margin="0,0,0,-6"
|
||||||
|
BorderThickness="1,1,1,0" CornerRadius="{DynamicResource TabCornerRadius}" IsHitTestVisible="False"
|
||||||
|
BorderBrush="{DynamicResource PaneEdgeBrush}"/>
|
||||||
|
<DockPanel LastChildFill="True">
|
||||||
|
<!-- Overflow chevron: every tab in this pane, hidden ones included
|
||||||
|
(TabOverflowMenu). Docked BEFORE the strip so the strip measures against
|
||||||
|
the band's REMAINING width - docked after, the strip would take the whole
|
||||||
|
band and the chevron would sit on top of the last tab.
|
||||||
|
Collapsed while everything fits, which is the normal case: a chevron that
|
||||||
|
is always there is a control that does nothing most of the time, and its
|
||||||
|
26px would come out of the tabs to say so. -->
|
||||||
|
<!-- DynamicResource, NOT StaticResource, on the style - same reason as
|
||||||
|
GrainBrushShared above. TabNewButton lives in MainWindow.Resources, and
|
||||||
|
StaticResource resolves at parse time against this control's own scope,
|
||||||
|
before the control is in the window's tree, so it throws from
|
||||||
|
InitializeComponent. -->
|
||||||
|
<Button x:Name="TabOverflowBtn" DockPanel.Dock="Right" Visibility="Collapsed"
|
||||||
|
Style="{DynamicResource TabNewButton}"
|
||||||
|
Content="" FontFamily="{DynamicResource IconFont}" FontSize="10"
|
||||||
|
Width="22" Height="20" Margin="2,4,2,3" VerticalAlignment="Center"
|
||||||
|
FocusVisualStyle="{x:Null}"
|
||||||
|
ToolTip="{DynamicResource Str_TT_TabOverflow}" Click="TabOverflow_Click"/>
|
||||||
|
<!-- No side insets. The strip has to be flush with the card beneath it, or the
|
||||||
|
first tab's left edge sits 6px inside the card's left border and the ring
|
||||||
|
steps sideways where it should run straight up. -->
|
||||||
|
<!-- Horizontal scrolling is DISABLED, not hidden: hidden still measures the
|
||||||
|
content at infinite width, which would let the tabs size to their own
|
||||||
|
content and defeat the UniformGrid below. Disabled constrains them to the
|
||||||
|
band's actual width, which is the point.
|
||||||
|
FocusVisualStyle nulled: a ScrollViewer is focusable, and once focus lands
|
||||||
|
here WPF draws its stock dotted rectangle around the whole strip. -->
|
||||||
|
<ScrollViewer x:Name="TabScroll" VerticalScrollBarVisibility="Disabled" HorizontalScrollBarVisibility="Disabled"
|
||||||
|
Background="Transparent" FocusVisualStyle="{x:Null}"
|
||||||
|
UseLayoutRounding="True" SnapsToDevicePixels="True"
|
||||||
|
MouseLeftButtonDown="TabScroll_MouseLeftButtonDown">
|
||||||
|
<!-- Keep the edge overlays in the SAME viewport as the tab containers.
|
||||||
|
When they were siblings of the ScrollViewer they started from the
|
||||||
|
band's origin while the tabs started from the viewport's origin,
|
||||||
|
producing the three-pixel vertical mismatch at the scrollbar corner. -->
|
||||||
|
<Grid UseLayoutRounding="True" SnapsToDevicePixels="True">
|
||||||
|
<ItemsControl x:Name="TabStrip" UseLayoutRounding="True" SnapsToDevicePixels="True">
|
||||||
|
<!-- The ACTIVE tab draws above its neighbors. Items paint in index
|
||||||
|
order, so the tab to the RIGHT of the active one painted over the
|
||||||
|
active tab's 1px right border and the ring stopped dead at that
|
||||||
|
side - which is why a middle tab lost its right edge while the last
|
||||||
|
tab, having no neighbor after it, kept both. -->
|
||||||
|
<ItemsControl.ItemContainerStyle>
|
||||||
|
<Style TargetType="ContentPresenter">
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsActive}" Value="True">
|
||||||
|
<Setter Property="Panel.ZIndex" Value="1"/>
|
||||||
|
</DataTrigger>
|
||||||
|
<!-- Windowed out of the strip and living in the chevron
|
||||||
|
instead (ApplyTabWindow). Collapsed rather than removed
|
||||||
|
from the collection, which is what makes this cheap:
|
||||||
|
UniformGrid does not count a collapsed child when it
|
||||||
|
divides the band, so the survivors fill it edge to edge
|
||||||
|
on their own, and nothing about the tabs' order or
|
||||||
|
their drag indices moves. -->
|
||||||
|
<DataTrigger Binding="{Binding IsStripVisible}" Value="False">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</ItemsControl.ItemContainerStyle>
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<!-- Tabs share the band equally and fill it, browser-style, instead
|
||||||
|
of hugging their titles and leaving dead space on the right.
|
||||||
|
This is also what makes the last tab always REACH the strip's
|
||||||
|
right edge, so edge ownership is a fact rather than something
|
||||||
|
that has to be measured after every reflow. -->
|
||||||
|
<ItemsPanelTemplate><UniformGrid Rows="1"/></ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<!-- No DataType: the item is PdfViewer.DocumentSession, an internal
|
||||||
|
nested type, which x:Type cannot name. -->
|
||||||
|
<DataTemplate>
|
||||||
|
<!-- No MaxWidth: it would cap each cell and put the dead space
|
||||||
|
back. The MinWidth floor is low so a lot of open tabs still
|
||||||
|
divide the band rather than overflowing it.
|
||||||
|
CornerRadius matches the card's 6: the rightmost tab sits
|
||||||
|
directly above the card's top-right corner, and a 4px curve
|
||||||
|
stacked on a 6px one reads as a mistake rather than as two
|
||||||
|
rounded things.
|
||||||
|
No right MARGIN: the 1px right BorderThickness below
|
||||||
|
already separates one tab from the next, and a margin only
|
||||||
|
stopped the last tab reaching the strip's right edge -
|
||||||
|
which is the card's edge, so it left a 1px step under the
|
||||||
|
corner.
|
||||||
|
1px bottom margin keeps an inactive tab clear of
|
||||||
|
TabBarRing; the active tab drops it and covers its own
|
||||||
|
segment, which is what breaks that line exactly at the tab
|
||||||
|
and makes the browser-tab join. -->
|
||||||
|
<Border x:Name="tabBd" CornerRadius="{DynamicResource TabCornerRadius}" Margin="{DynamicResource TabMargin}" Padding="{DynamicResource TabPadding}"
|
||||||
|
MinWidth="60"
|
||||||
|
UseLayoutRounding="True" SnapsToDevicePixels="True"
|
||||||
|
Cursor="Hand" Background="Transparent"
|
||||||
|
BorderThickness="0,0,1,0" BorderBrush="{DynamicResource PaneEdgeBrush}"
|
||||||
|
ToolTip="{Binding TabTip}"
|
||||||
|
MouseDown="Tab_MouseDown"
|
||||||
|
MouseRightButtonUp="Tab_RightClick"
|
||||||
|
PreviewMouseLeftButtonDown="Tab_DragDown"
|
||||||
|
PreviewMouseMove="Tab_DragMove"
|
||||||
|
PreviewMouseLeftButtonUp="Tab_DragUp">
|
||||||
|
<Grid>
|
||||||
|
<!-- Grain follows the tab's radius, or the texture
|
||||||
|
squares off the corner the border just rounded. -->
|
||||||
|
<Border x:Name="tabGrain" IsHitTestVisible="False"
|
||||||
|
CornerRadius="{DynamicResource TabCornerRadius}" Margin="-12,-4,-5,-5"
|
||||||
|
Background="{DynamicResource GrainTileBrush}" Opacity="0"/>
|
||||||
|
<DockPanel LastChildFill="True">
|
||||||
|
<Button DockPanel.Dock="Right" Content=""
|
||||||
|
Style="{DynamicResource TabCloseButton}"
|
||||||
|
FontFamily="{DynamicResource IconFont}" FontSize="9"
|
||||||
|
Width="16" Height="16" Margin="6,0,0,0" Padding="0"
|
||||||
|
VerticalAlignment="Center" FocusVisualStyle="{x:Null}"
|
||||||
|
ToolTip="{DynamicResource Str_TT_CloseTab}"
|
||||||
|
Tag="{Binding}" Click="CloseTab_Click"/>
|
||||||
|
<TextBlock x:Name="tabLbl" Text="{Binding TabLabel}"
|
||||||
|
FontFamily="{DynamicResource UiFont}" FontSize="11"
|
||||||
|
Foreground="{DynamicResource MutedTextBrush}" VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
</DockPanel>
|
||||||
|
<Border x:Name="tabBevelLight" IsHitTestVisible="False" Panel.ZIndex="4"
|
||||||
|
Margin="{DynamicResource TabBevelMargin}" SnapsToDevicePixels="True"
|
||||||
|
BorderBrush="{DynamicResource BevelLightBrush}" BorderThickness="{DynamicResource BevelLightThickness}"/>
|
||||||
|
<Border x:Name="tabBevelDark" IsHitTestVisible="False" Panel.ZIndex="4"
|
||||||
|
Margin="{DynamicResource TabBevelMargin}" SnapsToDevicePixels="True"
|
||||||
|
BorderBrush="{DynamicResource BevelDarkBrush}" BorderThickness="{DynamicResource TabInactiveBevelDarkThickness}"/>
|
||||||
|
<!-- Second, inset highlight of the selected 98SE tab. Together
|
||||||
|
with tabBevelLight this makes the tab a raised two-tone edge,
|
||||||
|
matching the pane's outer and inner highlights instead of
|
||||||
|
degenerating into a flat rule at the tab/pane join. Other
|
||||||
|
themes resolve these resources to a transparent zero edge. -->
|
||||||
|
<Border x:Name="tabActiveRetroInnerBevel" IsHitTestVisible="False" Panel.ZIndex="6"
|
||||||
|
Margin="{DynamicResource TabActiveInnerBevelMargin}"
|
||||||
|
BorderBrush="{DynamicResource TabActiveInnerBevelBrush}"
|
||||||
|
BorderThickness="{DynamicResource TabActiveInnerBevelThickness}"
|
||||||
|
SnapsToDevicePixels="True" Visibility="Collapsed"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<DataTemplate.Triggers>
|
||||||
|
<!-- Last tab drops its right border: that 1px is a divider
|
||||||
|
BETWEEN tabs, and on the strip's right edge it reads as
|
||||||
|
a stray rule instead. Declared BEFORE the IsActive
|
||||||
|
trigger so the active tab's accent-stripe thickness
|
||||||
|
still wins when the last tab is also the active one. -->
|
||||||
|
<DataTrigger Binding="{Binding IsLast}" Value="True">
|
||||||
|
<Setter TargetName="tabBd" Property="BorderThickness" Value="0"/>
|
||||||
|
</DataTrigger>
|
||||||
|
<MultiDataTrigger>
|
||||||
|
<MultiDataTrigger.Conditions>
|
||||||
|
<Condition Binding="{Binding IsFirst}" Value="True"/>
|
||||||
|
<Condition Binding="{Binding IsActive}" Value="False"/>
|
||||||
|
</MultiDataTrigger.Conditions>
|
||||||
|
<Setter TargetName="tabBd" Property="Margin" Value="{DynamicResource TabInactiveFirstMargin}"/>
|
||||||
|
</MultiDataTrigger>
|
||||||
|
<MultiDataTrigger>
|
||||||
|
<MultiDataTrigger.Conditions>
|
||||||
|
<Condition Binding="{Binding IsLast}" Value="True"/>
|
||||||
|
<Condition Binding="{Binding IsActive}" Value="False"/>
|
||||||
|
</MultiDataTrigger.Conditions>
|
||||||
|
<Setter TargetName="tabBd" Property="Margin" Value="{DynamicResource TabInactiveLastMargin}"/>
|
||||||
|
</MultiDataTrigger>
|
||||||
|
<DataTrigger Binding="{Binding IsActive}" Value="True">
|
||||||
|
<!-- Front tab takes the card's own canvas color, so the
|
||||||
|
tab and the card read as one surface. -->
|
||||||
|
<Setter TargetName="tabBd" Property="Background" Value="{DynamicResource BgCanvas}"/>
|
||||||
|
<Setter TargetName="tabGrain" Property="Opacity" Value="{DynamicResource GrainOpacity}"/>
|
||||||
|
<Setter TargetName="tabBd" Property="BorderThickness" Value="{DynamicResource TabStripeThickness}"/>
|
||||||
|
<!-- Reaches the foot of the band so its fill breaks the
|
||||||
|
ring line where the tab is. -->
|
||||||
|
<Setter TargetName="tabBd" Property="Margin" Value="0,3,0,0"/>
|
||||||
|
<!-- Top padding gives back the 3px the accent stripe
|
||||||
|
takes, so the title does not drop on activation. -->
|
||||||
|
<Setter TargetName="tabBd" Property="Padding" Value="{DynamicResource TabActivePadding}"/>
|
||||||
|
<Setter TargetName="tabBd" Property="BorderBrush" Value="{DynamicResource TabActiveRingBrush}"/>
|
||||||
|
<!-- Modern themes use the original raised-tab shadow. 98SE
|
||||||
|
disables it in the theme-gated trigger below because an Effect
|
||||||
|
rasterizes the label and destroys the crisp classic text. -->
|
||||||
|
<Setter TargetName="tabBd" Property="Effect" Value="{DynamicResource BarShadowEffect}"/>
|
||||||
|
<Setter TargetName="tabLbl" Property="Foreground" Value="{DynamicResource TextBrush}"/>
|
||||||
|
<Setter TargetName="tabLbl" Property="FontWeight" Value="Bold"/>
|
||||||
|
<Setter TargetName="tabLbl" Property="FontSize" Value="11.5"/>
|
||||||
|
<Setter TargetName="tabBevelDark" Property="BorderThickness" Value="{DynamicResource TabActiveBevelDarkThickness}"/>
|
||||||
|
<Setter TargetName="tabBevelDark" Property="Margin" Value="{DynamicResource TabActiveBevelDarkMargin}"/>
|
||||||
|
<Setter TargetName="tabActiveRetroInnerBevel" Property="Visibility" Value="{DynamicResource RetroActiveTabOutlineVisibility}"/>
|
||||||
|
</DataTrigger>
|
||||||
|
<DataTrigger Binding="{Binding UseRetroTabChrome}" Value="True">
|
||||||
|
<Setter TargetName="tabBd" Property="Effect" Value="{x:Null}"/>
|
||||||
|
</DataTrigger>
|
||||||
|
<!-- A first 98SE tab follows the pane's inner light bevel,
|
||||||
|
one pixel inside its dark outer frame. Modern themes
|
||||||
|
resolve this token to the normal flush active margin. -->
|
||||||
|
<MultiDataTrigger>
|
||||||
|
<MultiDataTrigger.Conditions>
|
||||||
|
<Condition Binding="{Binding IsActive}" Value="True"/>
|
||||||
|
<Condition Binding="{Binding IsFirst}" Value="True"/>
|
||||||
|
</MultiDataTrigger.Conditions>
|
||||||
|
<Setter TargetName="tabBd" Property="Margin" Value="{DynamicResource TabActiveFirstMargin}"/>
|
||||||
|
</MultiDataTrigger>
|
||||||
|
<MultiDataTrigger>
|
||||||
|
<MultiDataTrigger.Conditions>
|
||||||
|
<Condition Binding="{Binding IsActive}" Value="True"/>
|
||||||
|
<Condition Binding="{Binding IsLast}" Value="True"/>
|
||||||
|
</MultiDataTrigger.Conditions>
|
||||||
|
<Setter TargetName="tabBd" Property="Margin" Value="{DynamicResource TabActiveLastMargin}"/>
|
||||||
|
</MultiDataTrigger>
|
||||||
|
<MultiDataTrigger>
|
||||||
|
<MultiDataTrigger.Conditions>
|
||||||
|
<Condition Binding="{Binding IsActive}" Value="True"/>
|
||||||
|
<Condition Binding="{Binding IsFirst}" Value="True"/>
|
||||||
|
<Condition Binding="{Binding IsLast}" Value="True"/>
|
||||||
|
</MultiDataTrigger.Conditions>
|
||||||
|
<Setter TargetName="tabBd" Property="Margin" Value="{DynamicResource TabActiveOnlyMargin}"/>
|
||||||
|
</MultiDataTrigger>
|
||||||
|
<!-- Unfocused pane: the lip drops off the accent entirely
|
||||||
|
and takes the card's own border color, so the whole
|
||||||
|
unlit ring - lip, band line and card border - is ONE
|
||||||
|
value. At full accent both panes claimed to be the live
|
||||||
|
one. The active tab is still obvious on an unfocused
|
||||||
|
pane from its canvas fill and bold title, which is how
|
||||||
|
a browser marks it too.
|
||||||
|
Mutually exclusive with PaneFocused below, and NOT
|
||||||
|
simply !PaneFocused: with one pane open both are false
|
||||||
|
and the lone pane's lip stays bright. -->
|
||||||
|
<DataTrigger Binding="{Binding PaneDimmed}" Value="True">
|
||||||
|
<Setter TargetName="tabBd" Property="BorderBrush" Value="{DynamicResource PaneBorderBrush}"/>
|
||||||
|
</DataTrigger>
|
||||||
|
<!-- Focused pane: the ring continues UP both sides of the
|
||||||
|
active tab, so the tab and the card read as one
|
||||||
|
outlined surface instead of the ring stopping dead at
|
||||||
|
the strip. Declared LAST on purpose - PaneFocused
|
||||||
|
implies IsActive, both triggers match, and with
|
||||||
|
multiple matches the last one wins. Put earlier,
|
||||||
|
IsActive would overwrite the thickness straight back to
|
||||||
|
a top-only stripe.
|
||||||
|
One brush for the whole lip, matching the card's ring:
|
||||||
|
a top stripe in one color meeting sides in another
|
||||||
|
reads as two edges rather than one ring.
|
||||||
|
Padding drops 1px each side to pay for the new borders,
|
||||||
|
so the title does not shift when focus arrives. -->
|
||||||
|
<DataTrigger Binding="{Binding PaneFocused}" Value="True">
|
||||||
|
<Setter TargetName="tabBd" Property="BorderThickness" Value="{DynamicResource TabFocusThickness}"/>
|
||||||
|
<Setter TargetName="tabBd" Property="Padding" Value="{DynamicResource TabFocusPadding}"/>
|
||||||
|
<Setter TargetName="tabBd" Property="BorderBrush" Value="{DynamicResource TabActiveRingBrush}"/>
|
||||||
|
</DataTrigger>
|
||||||
|
<!-- Pane shading is the 98SE focus cue only. Keeping it behind an
|
||||||
|
explicit theme flag preserves every modern palette's original
|
||||||
|
active-tab BgCanvas fill. -->
|
||||||
|
<MultiDataTrigger>
|
||||||
|
<MultiDataTrigger.Conditions>
|
||||||
|
<Condition Binding="{Binding UseRetroTabChrome}" Value="True"/>
|
||||||
|
<Condition Binding="{Binding PaneDimmed}" Value="True"/>
|
||||||
|
</MultiDataTrigger.Conditions>
|
||||||
|
<Setter TargetName="tabBd" Property="Background" Value="{DynamicResource TabInactiveBrush}"/>
|
||||||
|
</MultiDataTrigger>
|
||||||
|
<MultiDataTrigger>
|
||||||
|
<MultiDataTrigger.Conditions>
|
||||||
|
<Condition Binding="{Binding UseRetroTabChrome}" Value="True"/>
|
||||||
|
<Condition Binding="{Binding PaneFocused}" Value="True"/>
|
||||||
|
</MultiDataTrigger.Conditions>
|
||||||
|
<Setter TargetName="tabBd" Property="Background" Value="{DynamicResource FocusedPaneBrush}"/>
|
||||||
|
</MultiDataTrigger>
|
||||||
|
<!-- The OUTERMOST side is the band's to draw (TabEdgeLeft /
|
||||||
|
TabEdgeRight), so the first and last tab must not draw
|
||||||
|
it as well. Two 1px borders at the same x is a 2px edge
|
||||||
|
on a ring that is 1px everywhere else - and only
|
||||||
|
SOMETIMES, because whether the tab's own outer border
|
||||||
|
clears the ScrollViewer's clip depends on how the
|
||||||
|
UniformGrid divided a fractional band width. That is
|
||||||
|
where the halo came out uneven, and why it changed when
|
||||||
|
the split moved.
|
||||||
|
The padding gives the pixel back on that side so the
|
||||||
|
title does not shift, exactly as PaneFocused does.
|
||||||
|
Declared after PaneFocused - all three match on a first
|
||||||
|
or last tab and the last match wins. -->
|
||||||
|
<MultiDataTrigger>
|
||||||
|
<MultiDataTrigger.Conditions>
|
||||||
|
<Condition Binding="{Binding PaneFocused}" Value="True"/>
|
||||||
|
<Condition Binding="{Binding IsFirst}" Value="True"/>
|
||||||
|
</MultiDataTrigger.Conditions>
|
||||||
|
<Setter TargetName="tabBd" Property="BorderThickness" Value="{DynamicResource TabFocusFirstThickness}"/>
|
||||||
|
<Setter TargetName="tabBd" Property="Padding" Value="{DynamicResource TabFocusFirstPadding}"/>
|
||||||
|
</MultiDataTrigger>
|
||||||
|
<MultiDataTrigger>
|
||||||
|
<MultiDataTrigger.Conditions>
|
||||||
|
<Condition Binding="{Binding PaneFocused}" Value="True"/>
|
||||||
|
<Condition Binding="{Binding IsLast}" Value="True"/>
|
||||||
|
</MultiDataTrigger.Conditions>
|
||||||
|
<Setter TargetName="tabBd" Property="BorderThickness" Value="{DynamicResource TabFocusLastThickness}"/>
|
||||||
|
<Setter TargetName="tabBd" Property="Padding" Value="{DynamicResource TabFocusLastPadding}"/>
|
||||||
|
</MultiDataTrigger>
|
||||||
|
<!-- One tab in a focused pane owns BOTH edges, so it draws
|
||||||
|
neither. Last of the three for the same reason. -->
|
||||||
|
<MultiDataTrigger>
|
||||||
|
<MultiDataTrigger.Conditions>
|
||||||
|
<Condition Binding="{Binding PaneFocused}" Value="True"/>
|
||||||
|
<Condition Binding="{Binding IsFirst}" Value="True"/>
|
||||||
|
<Condition Binding="{Binding IsLast}" Value="True"/>
|
||||||
|
</MultiDataTrigger.Conditions>
|
||||||
|
<Setter TargetName="tabBd" Property="BorderThickness" Value="{DynamicResource TabFocusOnlyThickness}"/>
|
||||||
|
<Setter TargetName="tabBd" Property="Padding" Value="{DynamicResource TabFocusOnlyPadding}"/>
|
||||||
|
</MultiDataTrigger>
|
||||||
|
</DataTemplate.Triggers>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<!-- The outer gray frame pixels. The tabs reserve their edge pixel and
|
||||||
|
draw the inner white/gray bevel themselves; these borders complete
|
||||||
|
only the outermost frame and therefore cannot thicken the bevel. -->
|
||||||
|
<Border x:Name="TabEdgeLeft" Width="1" HorizontalAlignment="Left"
|
||||||
|
IsHitTestVisible="False" Visibility="Collapsed"/>
|
||||||
|
<Border x:Name="TabEdgeRight" Width="1" HorizontalAlignment="Right"
|
||||||
|
IsHitTestVisible="False" Visibility="Collapsed"/>
|
||||||
|
</Grid>
|
||||||
|
</ScrollViewer>
|
||||||
|
</DockPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ── This pane's card ───────────────────────────────────────────────────────────
|
||||||
|
Row 1. The -1 top margin tucks the card's top border up into the strip band so the
|
||||||
|
active tab and the card read as one surface (see TabBarRing).
|
||||||
|
|
||||||
|
Margin is set in CODE, not here: it must be -1 only while this pane HAS a strip.
|
||||||
|
A pane with one tab collapses its strip, and the -1 then lifts that pane a pixel above
|
||||||
|
the other one instead of tucking under anything. RebuildTabStrip owns both. -->
|
||||||
|
<Grid x:Name="CardRow" Grid.Row="1">
|
||||||
|
<!-- Separate shadow caster keeps the pane content crisp while allowing each modern
|
||||||
|
theme to supply its intended elevation. 98SE sets PaneShadowOpacity to zero. -->
|
||||||
|
<!-- DynamicResource PaneShadowEffect, built per theme in ThemeManager (null on 98SE) -
|
||||||
|
the old app-level StaticResource effect froze with its startup opacity and 98SE's
|
||||||
|
zero never applied. -->
|
||||||
|
<Border x:Name="PaneShadow" IsHitTestVisible="False" Margin="0,1,0,0"
|
||||||
|
Background="{DynamicResource BgCanvas}"
|
||||||
|
CornerRadius="{DynamicResource RadCard}"
|
||||||
|
Effect="{DynamicResource PaneShadowEffect}"/>
|
||||||
|
<Border x:Name="PaneBorder" Panel.ZIndex="6"
|
||||||
|
Background="{DynamicResource BgCanvas}"
|
||||||
|
BorderBrush="{DynamicResource PaneBorderBrush}" BorderThickness="1"
|
||||||
|
CornerRadius="{DynamicResource RadCard}">
|
||||||
|
<!-- Clipped to the card's own corners (DocPane_SizeChanged). A Border with a
|
||||||
|
CornerRadius does NOT clip its child, so the canvas and the page square the
|
||||||
|
corners straight back off. Same treatment as KillerShell's PaneContent. -->
|
||||||
|
<Grid x:Name="DocPaneContent" SizeChanged="DocPane_SizeChanged">
|
||||||
|
<Border x:Name="PaneBevelOuterDark" Panel.ZIndex="100" IsHitTestVisible="False" BorderBrush="{DynamicResource PaneBevelDarkBrush}" BorderThickness="{DynamicResource PaneBevelLightThickness}"/>
|
||||||
|
<Border x:Name="PaneBevelOuterLight" Panel.ZIndex="100" IsHitTestVisible="False" BorderBrush="{DynamicResource PaneBevelLightBrush}" BorderThickness="{DynamicResource PaneBevelDarkThickness}"/>
|
||||||
|
<Border x:Name="PaneBevelInnerDark" Panel.ZIndex="100" IsHitTestVisible="False" Margin="{DynamicResource PaneBevelInnerMargin}" BorderBrush="{DynamicResource PaneBevelDark2Brush}" BorderThickness="{DynamicResource PaneBevel2LightThickness}"/>
|
||||||
|
<Border x:Name="PaneBevelInnerLight" Panel.ZIndex="100" IsHitTestVisible="False" Margin="{DynamicResource PaneBevelInnerMargin}" BorderBrush="{DynamicResource PaneBevelLight2Brush}" BorderThickness="{DynamicResource PaneBevel2DarkThickness}"/>
|
||||||
|
<!-- Film grain - sits on the canvas background, behind the document.
|
||||||
|
Needs no CornerRadius of its own: the clip on DocPaneContent rounds
|
||||||
|
everything inside the card, grain included. -->
|
||||||
|
<Border IsHitTestVisible="False" Opacity="{DynamicResource GrainOpacity}">
|
||||||
|
<Border.Background>
|
||||||
|
<ImageBrush x:Name="GrainBrush"
|
||||||
|
TileMode="Tile"
|
||||||
|
ViewportUnits="Absolute"
|
||||||
|
Viewport="0,0,256,256"
|
||||||
|
Stretch="None"/>
|
||||||
|
</Border.Background>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Drop zone -->
|
||||||
|
<Border x:Name="DropZone" Background="Transparent"
|
||||||
|
AllowDrop="True" Drop="DropZone_Drop" DragOver="DropZone_DragOver"
|
||||||
|
MouseLeftButtonDown="DropZone_Click">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- Drop target, centered in the remaining space -->
|
||||||
|
<Border Grid.Column="0" BorderBrush="{DynamicResource DropBorder}" BorderThickness="2"
|
||||||
|
CornerRadius="{DynamicResource PanelCornerRadius}" Padding="40"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
Background="Transparent" Style="{x:Null}">
|
||||||
|
<StackPanel HorizontalAlignment="Center">
|
||||||
|
<TextBlock Text="{DynamicResource Str_Drop_Title}" FontFamily="Segoe UI, Microsoft JhengHei UI, Nirmala UI" FontSize="20"
|
||||||
|
Foreground="{DynamicResource MutedTextBrush}" HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{DynamicResource Str_Drop_Sub}" FontFamily="Segoe UI, Microsoft JhengHei UI, Nirmala UI" FontSize="13"
|
||||||
|
Foreground="{DynamicResource MutedTextBrush}" HorizontalAlignment="Center" Margin="0,8,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Recent files sidebar (populated in code-behind; hidden when empty).
|
||||||
|
Width and visibility are driven by SyncRecentBoxWidth: at 340 fixed it
|
||||||
|
swamped the drop target in a half-width pane, so it now scales with the
|
||||||
|
pane and drops out entirely below a threshold. -->
|
||||||
|
<Border x:Name="RecentFilesBox" Grid.Column="1" Width="340" Visibility="Collapsed"
|
||||||
|
Background="{DynamicResource BgRecentPanel}"
|
||||||
|
BorderBrush="{DynamicResource PaneBorderBrush}" BorderThickness="1,0,0,0">
|
||||||
|
<Grid>
|
||||||
|
<Border Background="{DynamicResource GrainBrushShared}" IsHitTestVisible="False"
|
||||||
|
Opacity="{DynamicResource GrainOpacity}"/>
|
||||||
|
<DockPanel Margin="18,22,12,16">
|
||||||
|
<DockPanel DockPanel.Dock="Top" Margin="2,0,0,10">
|
||||||
|
<!-- Clear all (#146): one click empties the whole list, matching the
|
||||||
|
dropdown's Clear list item. Foreground lives in the style so the
|
||||||
|
hover trigger can override it (a local value would win over it). -->
|
||||||
|
<TextBlock DockPanel.Dock="Right" Text="{DynamicResource Str_Menu_ClearList}"
|
||||||
|
FontFamily="Segoe UI, Microsoft JhengHei UI, Nirmala UI"
|
||||||
|
FontSize="11" Cursor="Hand" Margin="8,0,6,0"
|
||||||
|
MouseLeftButtonDown="RecentClearAll_Click">
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource MutedTextBrush}"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource PrimaryBrush}"/>
|
||||||
|
<Setter Property="TextDecorations" Value="Underline"/>
|
||||||
|
</Trigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
|
<TextBlock Text="{DynamicResource Str_RecentHeading}" FontFamily="Segoe UI, Microsoft JhengHei UI, Nirmala UI"
|
||||||
|
FontSize="11" FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource PrimaryBrush}"/>
|
||||||
|
</DockPanel>
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<ItemsControl x:Name="RecentFilesList"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</DockPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- PDF page with annotation overlay -->
|
||||||
|
<ScrollViewer x:Name="PagePreviewPanel" Visibility="Collapsed"
|
||||||
|
HorizontalScrollBarVisibility="Auto"
|
||||||
|
VerticalScrollBarVisibility="Auto"
|
||||||
|
Background="Transparent"
|
||||||
|
FocusVisualStyle="{x:Null}"
|
||||||
|
PreviewMouseWheel="PagePreview_PreviewMouseWheel"
|
||||||
|
SizeChanged="PagePreviewPanel_SizeChanged"
|
||||||
|
PreviewMouseDown="PagePreviewPanel_PreviewMouseDown"
|
||||||
|
PreviewMouseMove="PagePreviewPanel_PreviewMouseMove"
|
||||||
|
PreviewMouseUp="PagePreviewPanel_PreviewMouseUp"
|
||||||
|
MouseRightButtonUp="DocPaneBackground_RightClick"
|
||||||
|
AllowDrop="True" Drop="DropZone_Drop" DragOver="DropZone_DragOver">
|
||||||
|
<!-- Custom template: the VERTICAL scrollbar spans the full height (RowSpan=2),
|
||||||
|
covering the bottom-right corner cell, and the horizontal bar butts up
|
||||||
|
against it. The default template puts a white system-colored corner
|
||||||
|
rectangle there, which read as a stray white square against the dark
|
||||||
|
canvas whenever both scrollbars were visible.
|
||||||
|
The PART_ names below are TEMPLATE-scoped and so are unaffected by this
|
||||||
|
control being its own namescope - do not "fix" them. -->
|
||||||
|
<ScrollViewer.Template>
|
||||||
|
<ControlTemplate TargetType="ScrollViewer">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<ScrollContentPresenter Grid.Row="0" Grid.Column="0"
|
||||||
|
Margin="{TemplateBinding Padding}"
|
||||||
|
Content="{TemplateBinding Content}"
|
||||||
|
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||||
|
CanContentScroll="{TemplateBinding CanContentScroll}"/>
|
||||||
|
<ScrollBar x:Name="PART_VerticalScrollBar" Grid.Row="0" Grid.Column="1" Grid.RowSpan="2"
|
||||||
|
Panel.ZIndex="101"
|
||||||
|
Value="{TemplateBinding VerticalOffset}"
|
||||||
|
Maximum="{TemplateBinding ScrollableHeight}"
|
||||||
|
ViewportSize="{TemplateBinding ViewportHeight}"
|
||||||
|
Visibility="{TemplateBinding ComputedVerticalScrollBarVisibility}"/>
|
||||||
|
<ScrollBar x:Name="PART_HorizontalScrollBar" Grid.Row="1" Grid.Column="0"
|
||||||
|
Panel.ZIndex="101"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
Value="{TemplateBinding HorizontalOffset}"
|
||||||
|
Maximum="{TemplateBinding ScrollableWidth}"
|
||||||
|
ViewportSize="{TemplateBinding ViewportWidth}"
|
||||||
|
Visibility="{TemplateBinding ComputedHorizontalScrollBarVisibility}"/>
|
||||||
|
</Grid>
|
||||||
|
</ControlTemplate>
|
||||||
|
</ScrollViewer.Template>
|
||||||
|
<Border x:Name="DocSurfacePad" VerticalAlignment="Top" HorizontalAlignment="Center" Padding="12">
|
||||||
|
<!-- Override the window's Display/ClearType text mode for the zoomed
|
||||||
|
document surface. Display + ClearType pixel-snaps and color-fringes
|
||||||
|
when scaled, giving hard-edged text on annotations and form fields;
|
||||||
|
Ideal + Grayscale stays smoothly anti-aliased at any zoom, matching
|
||||||
|
how other PDF editors render. -->
|
||||||
|
<Grid x:Name="PageContentGrid"
|
||||||
|
TextOptions.TextFormattingMode="Ideal"
|
||||||
|
TextOptions.TextRenderingMode="Grayscale">
|
||||||
|
<Grid.LayoutTransform>
|
||||||
|
<ScaleTransform ScaleX="1" ScaleY="1"/>
|
||||||
|
</Grid.LayoutTransform>
|
||||||
|
<WrapPanel x:Name="PageContentPanel" Orientation="Horizontal">
|
||||||
|
<!-- Tile 0 (the primary page) is built in code by BuildPrimaryTile and
|
||||||
|
inserted at index 0; additional pages are rendered dynamically too. -->
|
||||||
|
</WrapPanel>
|
||||||
|
<!-- Continuous scroll panel - shown in Continuous view mode -->
|
||||||
|
<StackPanel x:Name="ContinuousPanel"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Orientation="Vertical"
|
||||||
|
Visibility="Collapsed"/>
|
||||||
|
<!-- Top-most layer for the selection marquee, so a drag can span pages -->
|
||||||
|
<Canvas x:Name="MarqueeLayer" IsHitTestVisible="False"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- #197: current-page badge. Replaces the cursor-following page tooltips: slides
|
||||||
|
up from the bottom corner on scroll and page changes, slides back down when
|
||||||
|
the view settles (ShowPageBadge, Viewport.cs). Never hit-testable, so it
|
||||||
|
cannot eat a page click. 26px right margin clears the vertical scrollbar. -->
|
||||||
|
<Grid x:Name="PageBadge" HorizontalAlignment="Right" VerticalAlignment="Bottom"
|
||||||
|
Margin="0,0,26,14" Panel.ZIndex="40" IsHitTestVisible="False" Opacity="0">
|
||||||
|
<Grid.RenderTransform>
|
||||||
|
<TranslateTransform x:Name="PageBadgeSlide" Y="46"/>
|
||||||
|
</Grid.RenderTransform>
|
||||||
|
|
||||||
|
<!-- Cast from a separate empty rectangle so the effect never rasterizes the
|
||||||
|
badge text. BarShadowOpacity keeps the weight consistent with the chrome
|
||||||
|
and lets 98SE suppress it without a special-case code path. -->
|
||||||
|
<Border Background="{DynamicResource MenuBackgroundBrush}"
|
||||||
|
CornerRadius="{DynamicResource ControlCornerRadius}">
|
||||||
|
<Border.Effect>
|
||||||
|
<DropShadowEffect Color="Black" BlurRadius="7" ShadowDepth="2"
|
||||||
|
Direction="270" Opacity="{DynamicResource BarShadowOpacity}"
|
||||||
|
RenderingBias="Quality"/>
|
||||||
|
</Border.Effect>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Background="{DynamicResource MenuBackgroundBrush}"
|
||||||
|
BorderBrush="{DynamicResource MenuBorderBrush}" BorderThickness="1"
|
||||||
|
CornerRadius="{DynamicResource ControlCornerRadius}" Padding="9,3">
|
||||||
|
<Grid>
|
||||||
|
<TextBlock x:Name="PageBadgeText" FontFamily="Consolas" FontSize="12"
|
||||||
|
Foreground="{DynamicResource TextBrush}"/>
|
||||||
|
<!-- Grain OVER the content, family rule -->
|
||||||
|
<Border IsHitTestVisible="False" Margin="-9,-3"
|
||||||
|
CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||||
|
Background="{DynamicResource GrainTileBrush}" Opacity="{DynamicResource GrainOpacity}"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid><!-- /card row -->
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using KillerPDF.Features;
|
||||||
|
|
||||||
|
namespace KillerPDF.Controls
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One document view: its tab strip, its card and everything inside. Two instances make the
|
||||||
|
/// split.
|
||||||
|
///
|
||||||
|
/// The handlers here are one-line forwards to the host, following KillerShell's FilePane idiom -
|
||||||
|
/// chrome that belongs to the window stays on the window.
|
||||||
|
/// </summary>
|
||||||
|
public partial class PdfViewer : UserControl
|
||||||
|
{
|
||||||
|
/// <summary>The explicit shell boundary used by the viewer.</summary>
|
||||||
|
internal IViewerHost? Host { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Document dark-mode invert, PER PANE (was a global that flipped both panes of
|
||||||
|
/// a split at once). Display only; every render path in this pane reads this flag. The
|
||||||
|
/// moon toggles the focused pane and its lit state follows pane focus.</summary>
|
||||||
|
internal bool DocInvert;
|
||||||
|
|
||||||
|
internal void AttachHost(IViewerHost host) => Host = host;
|
||||||
|
|
||||||
|
/// <summary>This viewer's per-view state - page maps, view mode, zoom, render cancellation,
|
||||||
|
/// continuous bookkeeping. MainWindow's `_view` reads it back from here, so a second pane
|
||||||
|
/// gets its own simply by existing.</summary>
|
||||||
|
internal ViewerState State { get; } = new();
|
||||||
|
|
||||||
|
public PdfViewer() => InitializeComponent();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build this pane's tile tree. Every pane must do this for itself: routed through
|
||||||
|
/// ActiveViewer it would run twice on pane A and leave pane B's State.AnnotationCanvas
|
||||||
|
/// null, which AllPageCanvases() yields first and the first text-selection repaint then
|
||||||
|
/// dereferences. The panel references must be assigned before BuildPrimaryTile, which
|
||||||
|
/// inserts into PageContentPanel.
|
||||||
|
/// </summary>
|
||||||
|
internal void InitTiles()
|
||||||
|
{
|
||||||
|
State.PageContentGrid = PageContentGrid;
|
||||||
|
State.PageContentPanel = PageContentPanel;
|
||||||
|
State.ContinuousPanel = ContinuousPanel;
|
||||||
|
BuildPrimaryTile();
|
||||||
|
State.ActiveCanvas = State.AnnotationCanvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Accent ring marking this pane as the focused one in a split.
|
||||||
|
///
|
||||||
|
/// SetResourceReference, not a brush snapshot, on both states: an assigned brush would not
|
||||||
|
/// follow a live theme switch.
|
||||||
|
///
|
||||||
|
/// "SelectionAccent", not "AccentBrush". KillerPDF uses the older family resource set
|
||||||
|
/// (BgCanvas / AccentLogo / TextPrimary) and has no AccentBrush key in any theme.
|
||||||
|
/// SetResourceReference to a missing key does not throw, it silently leaves the property
|
||||||
|
/// unset, which blanks the border instead of accenting it.
|
||||||
|
///
|
||||||
|
/// Both borders, matching KillerShell's UpdatePaneFocusRing: TabBarRing is the card's top
|
||||||
|
/// border drawn again inside the tab band, so lighting only the card leaves the ring open
|
||||||
|
/// along its whole top edge.
|
||||||
|
///
|
||||||
|
/// The brush moves, never the thickness - a thickness change would reflow the pane on every
|
||||||
|
/// click between panes.
|
||||||
|
///
|
||||||
|
/// PaneHasFocus below records the same state for the tab halo, which continues the ring up
|
||||||
|
/// around the active tab in the focused pane only.</summary>
|
||||||
|
internal bool PaneHasFocus { get; private set; }
|
||||||
|
|
||||||
|
internal void SetFocusHalo(bool focused)
|
||||||
|
{
|
||||||
|
PaneHasFocus = focused;
|
||||||
|
bool retro = Services.ThemeManager.Current == Services.Theme.SE98;
|
||||||
|
string key = focused && !retro ? "SelectionAccent" : "PaneBorderBrush";
|
||||||
|
PaneBorder.SetResourceReference(Border.BorderBrushProperty, key);
|
||||||
|
// The 98SE band is the raised client's white top ledge. Replacing it with the gray
|
||||||
|
// outer-frame brush on focus made the tab/pane join visibly change after a click.
|
||||||
|
TabBarRing.SetResourceReference(Border.BorderBrushProperty,
|
||||||
|
retro ? "BevelLightBrush" : key);
|
||||||
|
// The ring runs on around the active tab, so it moves with the pane border. The tab's own
|
||||||
|
// share of that is a template trigger on PaneFocused / PaneDimmed, which this sets, plus
|
||||||
|
// the band-drawn outer verticals.
|
||||||
|
UpdatePaneFocusRing();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Element access for the window --------------------------------------------------
|
||||||
|
// A UserControl is its OWN NAMESCOPE, so the window's FindName cannot reach any of these -
|
||||||
|
// and it fails SILENTLY, returning null rather than throwing. The window ctor assigns
|
||||||
|
// _view.X from these instead, which works because those fields are forwarding properties
|
||||||
|
// onto ViewerState.
|
||||||
|
internal Border PaneShadowBorder => PaneShadow;
|
||||||
|
internal Border PaneCardBorder => PaneBorder;
|
||||||
|
internal Grid ContentHost => DocPaneContent;
|
||||||
|
internal StackPanel ContinuousHost => ContinuousPanel;
|
||||||
|
internal WrapPanel PageHost => PageContentPanel;
|
||||||
|
internal Grid PageGrid => PageContentGrid;
|
||||||
|
internal ScrollViewer PreviewScroller => PagePreviewPanel;
|
||||||
|
internal Border DropSurface => DropZone;
|
||||||
|
internal Border RecentBox => RecentFilesBox;
|
||||||
|
internal ItemsControl RecentList => RecentFilesList;
|
||||||
|
internal Canvas Marquee => MarqueeLayer;
|
||||||
|
internal Border SurfacePad => DocSurfacePad;
|
||||||
|
internal System.Windows.Media.ImageBrush Grain => GrainBrush;
|
||||||
|
|
||||||
|
// ---- Forwards to the owning window ----------------------------------------------------
|
||||||
|
// MainWindow's copies were private; they are internal now purely so these can reach them.
|
||||||
|
|
||||||
|
private void DocPane_SizeChanged(object s, SizeChangedEventArgs e)
|
||||||
|
{
|
||||||
|
Host?.ViewerSizeChanged(this, s, e);
|
||||||
|
|
||||||
|
// Window resizing changes a pane's usable width without going through the split-pane
|
||||||
|
// callbacks. Keep the empty-state recents panel on the same width gate in that path too.
|
||||||
|
// SyncRecentBoxWidth only writes materially changed values and guards re-entry, so the
|
||||||
|
// follow-up layout pass caused by crossing the threshold settles immediately.
|
||||||
|
if (e.WidthChanged) SyncRecentBoxWidth();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Size the start screen's Recent panel to this pane, and drop it entirely once the
|
||||||
|
/// pane is too narrow to carry both it and the drop target. At its old fixed 340 it took
|
||||||
|
/// most of a half-width pane and left the "Drop PDF here" zone as a sliver. Owns the
|
||||||
|
/// panel's visibility outright, so PopulateRecentFilesList defers to it rather than the two
|
||||||
|
/// of them setting it from different rules.</summary>
|
||||||
|
private bool _syncingRecentBox;
|
||||||
|
internal void SyncRecentBoxWidth()
|
||||||
|
{
|
||||||
|
if (RecentFilesBox is null || RecentFilesList is null || _syncingRecentBox) return;
|
||||||
|
_syncingRecentBox = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
double w = ActualWidth;
|
||||||
|
double want = Math.Min(340, Math.Max(220, w * 0.4));
|
||||||
|
var vis = RecentFilesList.Items.Count > 0 && w >= 560
|
||||||
|
? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
// Only write when the value actually changes. Both of these re-trigger layout, and
|
||||||
|
// this runs FROM a size handler - an unconditional assignment gives the layout pass
|
||||||
|
// something new to react to every time round and it never settles.
|
||||||
|
if (Math.Abs(RecentFilesBox.Width - want) > 0.5) RecentFilesBox.Width = want;
|
||||||
|
if (RecentFilesBox.Visibility != vis) RecentFilesBox.Visibility = vis;
|
||||||
|
}
|
||||||
|
finally { _syncingRecentBox = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus THIS pane before forwarding: the open path routes through ActiveViewer, and a
|
||||||
|
// drag-drop raises no PreviewMouseDown (the focus trigger), so a drop on the unfocused
|
||||||
|
// pane opened the file in the OTHER pane. FocusPane is cheap and idempotent.
|
||||||
|
private void DropZone_Drop(object s, DragEventArgs e) => Host?.ViewerDrop(this, s, e);
|
||||||
|
private void DropZone_DragOver(object s, DragEventArgs e) => Host?.ViewerDragOver(s, e);
|
||||||
|
private void DropZone_Click(object s, MouseButtonEventArgs e) => Host?.ViewerDropZoneClick(s, e);
|
||||||
|
|
||||||
|
private void RecentClearAll_Click(object s, MouseButtonEventArgs e) => Host?.ClearRecentFiles(s, e);
|
||||||
|
|
||||||
|
// The five preview/scroll handlers do NOT forward from here: their bodies are in this class
|
||||||
|
// (PdfViewer.Zoom.cs, PdfViewer.Viewport.cs), so a forward would call straight back into
|
||||||
|
// itself. The XAML binds them directly.
|
||||||
|
private void DocPaneBackground_RightClick(object s, MouseButtonEventArgs e) => Host?.ViewerBackgroundRightClick(s, e);
|
||||||
|
|
||||||
|
/// <summary>Empty space on this pane's tab strip drags the window, the way a strip in the
|
||||||
|
/// title-bar row would. Named apart from MainWindow's TitleBar_MouseLeftButtonDown, which
|
||||||
|
/// keeps the body - it is window chrome, not pane behavior.</summary>
|
||||||
|
private void TabScroll_MouseLeftButtonDown(object s, MouseButtonEventArgs e) => Host?.ViewerTabStripMouseDown(s, e);
|
||||||
|
|
||||||
|
/// <summary>This pane's active document, for the window's chrome. The session list lives
|
||||||
|
/// here, so the window asks the focused pane rather than owning one itself.</summary>
|
||||||
|
internal DocumentSession? ActiveSessionRef => _active;
|
||||||
|
|
||||||
|
/// <summary>Every open document in THIS pane. The quit prompt has to union both panes to
|
||||||
|
/// decide whether anything is unsaved, and the settings writer needs each pane's list.</summary>
|
||||||
|
internal System.Collections.ObjectModel.ObservableCollection<DocumentSession> SessionsRef => _sessions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media.Animation;
|
||||||
|
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
|
||||||
|
// Fade a window out on close: cancel the first close, animate opacity to 0, then close for real.
|
||||||
|
// DialogResult is set before Closing fires, so it survives the deferral.
|
||||||
|
internal static class WindowFx
|
||||||
|
{
|
||||||
|
public const int FadeMs = 150;
|
||||||
|
|
||||||
|
public static void EnableFadeClose(Window w, int ms = FadeMs)
|
||||||
|
{
|
||||||
|
bool fading = false;
|
||||||
|
bool readyToClose = false;
|
||||||
|
w.Closing += (s, e) =>
|
||||||
|
{
|
||||||
|
if (readyToClose) return; // our own post-fade Close - let it through
|
||||||
|
e.Cancel = true; // hold off the real close until the fade finishes
|
||||||
|
if (fading) return; // already fading - ignore repeat triggers
|
||||||
|
fading = true;
|
||||||
|
var anim = new DoubleAnimation(w.Opacity, 0, new Duration(TimeSpan.FromMilliseconds(ms)))
|
||||||
|
{
|
||||||
|
EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }
|
||||||
|
};
|
||||||
|
anim.Completed += (_, _) => { readyToClose = true; w.Close(); };
|
||||||
|
w.BeginAnimation(UIElement.OpacityProperty, anim);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace KillerPDF.Features
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Everything the About card does that is not drawing: reading the signature and release date,
|
||||||
|
/// hashing the exe, asking GitHub whether there is a newer release, and performing the
|
||||||
|
/// one-click self-update.
|
||||||
|
///
|
||||||
|
/// Holds no controls. Talks to the window only through <see cref="IAboutHost"/>, so the whole
|
||||||
|
/// of this file is testable against a stub host.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class AboutController
|
||||||
|
{
|
||||||
|
// The certificate subject is the legal name ("Open Source Developer Stephen Riley"), so the
|
||||||
|
// About card ties it back to the name people know. Gated on the subject actually being
|
||||||
|
// Steve's: a fork signed by somebody else must not claim the alias, and an unsigned build
|
||||||
|
// has no subject at all. Family standard, see code/CLAUDE.md.
|
||||||
|
private const string SignerName = "Stephen Riley";
|
||||||
|
private const string AkaName = "Steve the Killer";
|
||||||
|
|
||||||
|
private const string Repo = "https://github.com/SteveTheKiller/KillerPDF";
|
||||||
|
|
||||||
|
private readonly IAboutHost _host;
|
||||||
|
|
||||||
|
/// <summary>"vX.Y.Z" of the available update, set by the update check. Null until one is found.</summary>
|
||||||
|
private string? _updateTag;
|
||||||
|
|
||||||
|
internal AboutController(IAboutHost host) => _host = host;
|
||||||
|
|
||||||
|
/// <summary>The running assembly's version, three parts.</summary>
|
||||||
|
internal static string Version =>
|
||||||
|
System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "?";
|
||||||
|
|
||||||
|
/// <summary>Release date baked in from the csproj's ReleaseDate property, so a user can see
|
||||||
|
/// how old their build is. A file timestamp would not survive being copied and the PE linker
|
||||||
|
/// stamp is a build date, not a release date. Empty when the attribute is missing (an older
|
||||||
|
/// build), in which case the version line shows the version alone.</summary>
|
||||||
|
internal static string ReleaseDate
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
foreach (var a in System.Reflection.CustomAttributeExtensions.GetCustomAttributes
|
||||||
|
<System.Reflection.AssemblyMetadataAttribute>(
|
||||||
|
System.Reflection.Assembly.GetExecutingAssembly()))
|
||||||
|
if (a.Key == "ReleaseDate") return a.Value ?? string.Empty;
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Populates the card and shows it. The SHA-256 is slow, so it lands later.</summary>
|
||||||
|
internal void Show()
|
||||||
|
{
|
||||||
|
var (sigValid, sigSubject, sigThumbprint) = App.GetExeSignerInfo();
|
||||||
|
|
||||||
|
_host.Publisher = sigValid ? sigSubject : "(not signed or chain failed)";
|
||||||
|
_host.Thumbprint = string.IsNullOrEmpty(sigThumbprint) ? "(none)" : sigThumbprint;
|
||||||
|
_host.Sha256 = _host.Loc("Str_About_Computing");
|
||||||
|
_host.ReleaseDate = ReleaseDate;
|
||||||
|
|
||||||
|
_host.SetVersion(Version);
|
||||||
|
|
||||||
|
// Signed, verified, AND signed by Steve - all three, not merely "is signed".
|
||||||
|
bool signedByMe = sigValid
|
||||||
|
&& sigSubject.IndexOf(SignerName, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
// 0x201C / 0x201D are the curly quotes, built from codepoints so this file stays ASCII
|
||||||
|
// on disk - the same encoding trap that made release.ps1 PS7-only.
|
||||||
|
_host.SetAlias(signedByMe ? (char)0x201C + AkaName + (char)0x201D : null);
|
||||||
|
|
||||||
|
_host.UpdateVisible = false;
|
||||||
|
_host.ShowCard();
|
||||||
|
|
||||||
|
CheckForUpdateAsync(System.Reflection.Assembly.GetExecutingAssembly().GetName().Version);
|
||||||
|
ComputeSha256Async();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Opens the GitHub release for the running version.</summary>
|
||||||
|
internal void OpenReleaseNotes() => OpenUrl($"{Repo}/releases/tag/v{Version}");
|
||||||
|
|
||||||
|
internal static void OpenUrl(string url)
|
||||||
|
{
|
||||||
|
try { Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); }
|
||||||
|
catch { /* no browser, or the shell refused - nothing useful to say */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- SHA-256 -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async void ComputeSha256Async()
|
||||||
|
{
|
||||||
|
var sha256 = await System.Threading.Tasks.Task.Run(App.GetExeSha256).ConfigureAwait(true);
|
||||||
|
_host.Sha256 = sha256;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Update check --------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Quietly checks GitHub for a newer release when the About card opens. Runs only on demand
|
||||||
|
/// (no background service), times out fast, and silently does nothing if there is no
|
||||||
|
/// internet or the request fails. Shows the update button only if a newer tag exists.
|
||||||
|
/// </summary>
|
||||||
|
private async void CheckForUpdateAsync(System.Version? current)
|
||||||
|
{
|
||||||
|
if (current is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Net.ServicePointManager.SecurityProtocol |= System.Net.SecurityProtocolType.Tls12;
|
||||||
|
using var http = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(4) };
|
||||||
|
http.DefaultRequestHeaders.UserAgent.ParseAdd("KillerPDF-UpdateCheck");
|
||||||
|
var json = await http.GetStringAsync($"{Repo.Replace("github.com", "api.github.com/repos")}/releases/latest")
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
|
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||||
|
if (!doc.RootElement.TryGetProperty("tag_name", out var tagEl)) return;
|
||||||
|
var tag = tagEl.GetString();
|
||||||
|
if (string.IsNullOrWhiteSpace(tag)) return;
|
||||||
|
// System.Version spelled out: this class has a string property called Version, which
|
||||||
|
// shadows the type in expression position, so a bare "Version.TryParse" binds to
|
||||||
|
// string.TryParse and does not compile.
|
||||||
|
if (!System.Version.TryParse(tag!.TrimStart('v', 'V').Trim(), out var latest)) return;
|
||||||
|
|
||||||
|
var cur = new System.Version(current.Major, current.Minor, current.Build < 0 ? 0 : current.Build);
|
||||||
|
var lat = new System.Version(latest.Major, latest.Minor, latest.Build < 0 ? 0 : latest.Build);
|
||||||
|
if (lat <= cur) return;
|
||||||
|
|
||||||
|
_updateTag = $"v{lat.ToString(3)}";
|
||||||
|
_host.UpdateText = string.Format(_host.Loc("Str_UpdateAvailable"), _updateTag);
|
||||||
|
_host.UpdateVisible = true;
|
||||||
|
}
|
||||||
|
catch { /* offline, timeout, or API error - quietly do nothing */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Self-update ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One-click self-update: downloads and verifies the public portable/installer. Installed
|
||||||
|
/// copies hand it the same payload-based install command used by a manual upgrade; portable
|
||||||
|
/// copies replace their original launcher after both launcher and inner app have exited.
|
||||||
|
/// </summary>
|
||||||
|
internal async void Update()
|
||||||
|
{
|
||||||
|
var tag = _updateTag;
|
||||||
|
if (string.IsNullOrEmpty(tag)) return;
|
||||||
|
|
||||||
|
if (_host.IsDirty)
|
||||||
|
{
|
||||||
|
KillerDialog.Show(_host.Window, _host.Loc("Str_Dlg_SaveBeforeUpdate"),
|
||||||
|
"KillerPDF", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirm = KillerDialog.Show(_host.Window,
|
||||||
|
string.Format(_host.Loc("Str_UpdatePrompt"), tag),
|
||||||
|
"KillerPDF", MessageBoxButton.OKCancel, MessageBoxImage.Question);
|
||||||
|
if (confirm != MessageBoxResult.OK) return;
|
||||||
|
|
||||||
|
_host.UpdateEnabled = false;
|
||||||
|
_host.UpdateText = _host.Loc("Str_UpdateDownloading");
|
||||||
|
|
||||||
|
string? newExe = await DownloadVerifiedAsync(tag!).ConfigureAwait(true);
|
||||||
|
if (newExe is null)
|
||||||
|
{
|
||||||
|
// Offline, timed out, or verification failed: restore the button and open the
|
||||||
|
// releases page so the user can update manually.
|
||||||
|
_host.UpdateEnabled = true;
|
||||||
|
_host.UpdateText = string.Format(_host.Loc("Str_UpdateAvailable"), tag);
|
||||||
|
OpenUrl($"{Repo}/releases/latest");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!LaunchSwapAndExit(newExe))
|
||||||
|
{
|
||||||
|
try { if (File.Exists(newExe)) File.Delete(newExe); } catch { }
|
||||||
|
_host.UpdateEnabled = true;
|
||||||
|
_host.UpdateText = string.Format(_host.Loc("Str_UpdateAvailable"), tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Downloads the release exe and checks it against the published checksum.
|
||||||
|
/// Returns the temp path, or null if anything at all went wrong.</summary>
|
||||||
|
private static async System.Threading.Tasks.Task<string?> DownloadVerifiedAsync(string tag)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Net.ServicePointManager.SecurityProtocol |= System.Net.SecurityProtocolType.Tls12;
|
||||||
|
using var http = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(90) };
|
||||||
|
http.DefaultRequestHeaders.UserAgent.ParseAdd("KillerPDF-UpdateCheck");
|
||||||
|
|
||||||
|
var exeUrl = $"{Repo}/releases/download/{tag}/KillerPDF.exe";
|
||||||
|
// Read the checksums from the release ASSET next to the exe, not from
|
||||||
|
// raw.githubusercontent at the tag. Both files are uploaded to the release
|
||||||
|
// together, so the hash can never drift from the exe the way a repo-committed
|
||||||
|
// file does when the tag/commit order gets muddled.
|
||||||
|
var sumsUrl = $"{Repo}/releases/download/{tag}/SHA256SUMS.txt";
|
||||||
|
|
||||||
|
var exeBytes = await http.GetByteArrayAsync(exeUrl).ConfigureAwait(false);
|
||||||
|
var sumsTxt = await http.GetStringAsync(sumsUrl).ConfigureAwait(false);
|
||||||
|
|
||||||
|
string? expected = null;
|
||||||
|
foreach (var line in sumsTxt.Replace("\r", "").Split('\n'))
|
||||||
|
{
|
||||||
|
if (line.TrimStart().StartsWith("KillerPDF.exe", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var parts = line.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (parts.Length >= 2) expected = parts[^1];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (string.IsNullOrEmpty(expected)) return null;
|
||||||
|
|
||||||
|
string actual;
|
||||||
|
using (var sha = System.Security.Cryptography.SHA256.Create())
|
||||||
|
actual = BitConverter.ToString(sha.ComputeHash(exeBytes)).Replace("-", "");
|
||||||
|
if (!actual.Equals(expected, StringComparison.OrdinalIgnoreCase)) return null;
|
||||||
|
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"KillerPDF_update_{Guid.NewGuid():N}.exe");
|
||||||
|
File.WriteAllBytes(path, exeBytes);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes the swap batch, starts it, and shuts the app down. Returns false if the
|
||||||
|
/// helper could not be started, in which case nothing has been changed.</summary>
|
||||||
|
private bool LaunchSwapAndExit(string newExe)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var curExe = Process.GetCurrentProcess().MainModule!.FileName;
|
||||||
|
var reopen = _host.FileToReopen;
|
||||||
|
var pid = Process.GetCurrentProcess().Id;
|
||||||
|
var relArg = string.IsNullOrEmpty(reopen) ? "" : $" \"{reopen}\"";
|
||||||
|
var bat = Path.Combine(Path.GetTempPath(), $"killerpdf_update_{Guid.NewGuid():N}.bat");
|
||||||
|
bool portable = App.IsPortable();
|
||||||
|
string? portableLauncher = Environment.GetEnvironmentVariable("KILLERPDF_LAUNCHER_PATH");
|
||||||
|
bool packagedPortable = portable && !string.IsNullOrWhiteSpace(portableLauncher) && File.Exists(portableLauncher);
|
||||||
|
|
||||||
|
if (!App.VerifyAuthenticode(newExe).Valid)
|
||||||
|
throw new InvalidDataException("The downloaded update is not signed by a trusted publisher.");
|
||||||
|
|
||||||
|
// A machine-wide install (Program Files, from winget, choco or an RMM) is not
|
||||||
|
// writable by a normal user, so the swap has to run elevated. This previously ran
|
||||||
|
// the batch unelevated and sent the copy to >nul with no errorlevel check, so on
|
||||||
|
// those installs it silently failed and then relaunched the OLD exe - the app
|
||||||
|
// appeared to "update" to the same version, with no error.
|
||||||
|
string updateTarget = packagedPortable ? portableLauncher! : curExe;
|
||||||
|
bool needsElevation = !CanWriteTo(Path.GetDirectoryName(updateTarget)!);
|
||||||
|
|
||||||
|
// When elevated, relaunch through explorer.exe so the app comes back at the user's
|
||||||
|
// normal integrity level rather than inheriting the elevated token. explorer.exe
|
||||||
|
// cannot forward arguments, so the currently-open file is not reopened on that
|
||||||
|
// path - a one-off convenience loss, preferred over leaving KillerPDF running as
|
||||||
|
// administrator for the rest of the session.
|
||||||
|
var script = new StringBuilder()
|
||||||
|
.AppendLine("@echo off")
|
||||||
|
.AppendLine(":waitapp")
|
||||||
|
.AppendLine($"tasklist /fi \"PID eq {pid}\" 2>nul | find \"{pid}\" >nul")
|
||||||
|
.AppendLine("if not errorlevel 1 ( ping -n 2 127.0.0.1 >nul & goto waitapp )");
|
||||||
|
|
||||||
|
if (packagedPortable)
|
||||||
|
{
|
||||||
|
if (int.TryParse(Environment.GetEnvironmentVariable("KILLERPDF_LAUNCHER_PID"), out int launcherPid))
|
||||||
|
{
|
||||||
|
script.AppendLine(":waitlauncher")
|
||||||
|
.AppendLine($"tasklist /fi \"PID eq {launcherPid}\" 2>nul | find \"{launcherPid}\" >nul")
|
||||||
|
.AppendLine("if not errorlevel 1 ( ping -n 2 127.0.0.1 >nul & goto waitlauncher )");
|
||||||
|
}
|
||||||
|
script.AppendLine($"attrib -r \"{portableLauncher}\" >nul 2>&1")
|
||||||
|
.AppendLine($"copy /y \"{newExe}\" \"{portableLauncher}\" >nul 2>&1")
|
||||||
|
.AppendLine("if errorlevel 1 goto failed")
|
||||||
|
.AppendLine(needsElevation
|
||||||
|
? $"start \"\" explorer.exe \"{portableLauncher}\""
|
||||||
|
: $"start \"\" \"{portableLauncher}\"{relArg}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
bool machineInstall = !CanWriteTo(Path.GetDirectoryName(curExe)!);
|
||||||
|
string installArg = machineInstall ? "/silent" : "/install-user";
|
||||||
|
script.AppendLine($"start /wait \"\" \"{newExe}\" {installArg}")
|
||||||
|
.AppendLine("if errorlevel 1 goto failed")
|
||||||
|
.AppendLine(needsElevation
|
||||||
|
? $"start \"\" explorer.exe \"{curExe}\""
|
||||||
|
: $"start \"\" \"{curExe}\"{relArg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
script.AppendLine("goto cleanup")
|
||||||
|
.AppendLine(":failed")
|
||||||
|
.AppendLine($"start \"\" \"{Repo}/releases/latest\"")
|
||||||
|
.AppendLine(":cleanup")
|
||||||
|
.AppendLine($"del \"{newExe}\" >nul 2>&1")
|
||||||
|
.AppendLine("del \"%~f0\" >nul 2>&1");
|
||||||
|
File.WriteAllText(bat, script.ToString());
|
||||||
|
|
||||||
|
var psi = new ProcessStartInfo("cmd.exe", $"/c \"{bat}\"")
|
||||||
|
{
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden,
|
||||||
|
UseShellExecute = true
|
||||||
|
};
|
||||||
|
if (needsElevation) psi.Verb = "runas"; // triggers the UAC prompt
|
||||||
|
|
||||||
|
// Declining UAC throws Win32Exception 1223, so only shut down once the helper is
|
||||||
|
// actually running - otherwise the app would close without updating.
|
||||||
|
Process.Start(psi);
|
||||||
|
Application.Current.Shutdown();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True if this process can create a file in <paramref name="dir"/>. Used to decide
|
||||||
|
/// whether the self-update swap needs elevating: Program Files installs are not writable by
|
||||||
|
/// a normal user, per-user installs under LOCALAPPDATA always are.</summary>
|
||||||
|
private static bool CanWriteTo(string dir)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var probe = Path.Combine(dir, $".kp_write_{Guid.NewGuid():N}.tmp");
|
||||||
|
using (new FileStream(probe, FileMode.CreateNew, FileAccess.Write, FileShare.None,
|
||||||
|
1, FileOptions.DeleteOnClose)) { }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
namespace KillerPDF.Features
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// What AboutController needs from the window hosting it, beyond the shared shell services.
|
||||||
|
///
|
||||||
|
/// Every member is a value or a plain string, never a control, so the controller holds no
|
||||||
|
/// reference to a TextBlock or a Button and can be driven by a stub in a test.
|
||||||
|
///
|
||||||
|
/// KillerPDF differs from Killendar's version in one way worth knowing: several of the About
|
||||||
|
/// card's lines are built as INLINES rather than set as text - the wordmark is two differently
|
||||||
|
/// styled runs, and the tagline, version and alias each carry a hyperlink. Constructing those
|
||||||
|
/// is UI work, so it stays in the shell and the controller hands over only the strings and the
|
||||||
|
/// one boolean the shell needs to decide what to build.
|
||||||
|
/// </summary>
|
||||||
|
internal interface IAboutHost : IShellServices
|
||||||
|
{
|
||||||
|
/// <summary>Code-signing subject, or the unsigned message.</summary>
|
||||||
|
string Publisher { set; }
|
||||||
|
|
||||||
|
/// <summary>Certificate thumbprint, or "(none)".</summary>
|
||||||
|
string Thumbprint { set; }
|
||||||
|
|
||||||
|
/// <summary>SHA-256 of the running exe. Set twice: the "computing" placeholder first,
|
||||||
|
/// then the real digest once the background hash finishes.</summary>
|
||||||
|
string Sha256 { set; }
|
||||||
|
|
||||||
|
/// <summary>Release date baked in from the csproj, shown muted opposite the version.
|
||||||
|
/// Empty on an older build that predates the attribute.</summary>
|
||||||
|
string ReleaseDate { set; }
|
||||||
|
|
||||||
|
/// <summary>Builds the version line as a hyperlink through to that release tag.</summary>
|
||||||
|
void SetVersion(string version);
|
||||||
|
|
||||||
|
/// <summary>The quoted alias line. Null hides it - which is the case unless the exe is
|
||||||
|
/// signed AND the signature verifies AND the subject is Steve's, because a fork signed by
|
||||||
|
/// somebody else must not claim the alias.</summary>
|
||||||
|
void SetAlias(string? alias);
|
||||||
|
|
||||||
|
/// <summary>Whether a newer release exists, and whether the button is live while a
|
||||||
|
/// download is running.</summary>
|
||||||
|
string UpdateText { set; }
|
||||||
|
bool UpdateVisible { set; }
|
||||||
|
bool UpdateEnabled { set; }
|
||||||
|
|
||||||
|
/// <summary>Blocks a self-update while there are unsaved changes.</summary>
|
||||||
|
bool IsDirty { get; }
|
||||||
|
|
||||||
|
/// <summary>The document to reopen after the update relaunches, if any.</summary>
|
||||||
|
string? FileToReopen { get; }
|
||||||
|
|
||||||
|
/// <summary>Dismisses any other full-window overlay, then fades the About card in.
|
||||||
|
/// The overlays are mutually exclusive rather than stacking.</summary>
|
||||||
|
void ShowCard();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using PdfSharpCore.Pdf;
|
||||||
|
using PdfSharpCore.Pdf.IO;
|
||||||
|
using KillerPDF.Services;
|
||||||
|
|
||||||
|
namespace KillerPDF.Features
|
||||||
|
{
|
||||||
|
// ============================================================
|
||||||
|
// Headless CLI batch mode
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// KillerPDF.exe --batch-resave <input.pdf|inputDir> <output.pdf|outputDir> [--log <file.csv>] [--quiet]
|
||||||
|
//
|
||||||
|
// Resaves one PDF (or every *.pdf under a folder tree, mirroring the
|
||||||
|
// relative structure into the output folder) through the same pipeline a
|
||||||
|
// GUI save uses: PdfReader.Open(Modify), PdfScrub.ScrubEmptyOutlines,
|
||||||
|
// PdfScrub.ScrubDegenerateCropBoxes, PdfScrub.StripLinkAnnotationBorders, Save. No window,
|
||||||
|
// no dialogs, no repair fallbacks, no encryption stripping - files that
|
||||||
|
// cannot go through the plain Modify pipeline are reported as SKIP with a
|
||||||
|
// reason instead of silently faking a result.
|
||||||
|
//
|
||||||
|
// Built for the veraPDF validation harness (validation/): baseline the
|
||||||
|
// corpus, --batch-resave it, validate the output tree, then diff with
|
||||||
|
// validation/Compare-VeraPDF.ps1. The claim being tested is "a KillerPDF
|
||||||
|
// save does not degrade standards conformance".
|
||||||
|
//
|
||||||
|
// Exit codes: 0 = every file OK or SKIP, 1 = at least one FAIL
|
||||||
|
// (file opened but the save failed), 2 = bad usage or bad paths.
|
||||||
|
//
|
||||||
|
// Invoked from App.OnStartup BEFORE the single-instance mutex, so a batch
|
||||||
|
// run works even while a GUI instance is open and never forwards to it.
|
||||||
|
//
|
||||||
|
// KillerPDF builds as a GUI-subsystem exe, so it has no console of its
|
||||||
|
// own; AttachConsole(-1) latches onto the parent terminal when launched
|
||||||
|
// from one. Output interleaves with the prompt (standard GUI-app quirk) -
|
||||||
|
// the authoritative record is the --log CSV and the exit code.
|
||||||
|
//
|
||||||
|
// All static, never touches the window - extracted from MainWindow 2026-07-31.
|
||||||
|
internal static class BatchRunner
|
||||||
|
{
|
||||||
|
[DllImport("kernel32.dll", EntryPoint = "AttachConsole", SetLastError = true)]
|
||||||
|
private static extern bool BatchAttachConsole(int dwProcessId);
|
||||||
|
private const int BatchAttachParentProcess = -1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entry point for CLI batch mode. Returns false when args do not
|
||||||
|
/// request it (normal GUI launch); otherwise runs the whole batch and
|
||||||
|
/// returns true with the process exit code in <paramref name="exitCode"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool TryRunBatch(string[] args, out int exitCode)
|
||||||
|
{
|
||||||
|
exitCode = 0;
|
||||||
|
int flagIdx = Array.FindIndex(args,
|
||||||
|
a => string.Equals(a, "--batch-resave", StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (flagIdx < 0) return false;
|
||||||
|
|
||||||
|
var con = OpenBatchConsole();
|
||||||
|
|
||||||
|
// Positional args after the flag: input, output. Options anywhere after the flag.
|
||||||
|
string? input = null, output = null, logPath = null;
|
||||||
|
bool quiet = false, badUsage = false;
|
||||||
|
for (int i = flagIdx + 1; i < args.Length; i++)
|
||||||
|
{
|
||||||
|
var a = args[i];
|
||||||
|
if (string.Equals(a, "--log", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (i + 1 < args.Length) logPath = args[++i];
|
||||||
|
else badUsage = true;
|
||||||
|
}
|
||||||
|
else if (string.Equals(a, "--quiet", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
quiet = true;
|
||||||
|
}
|
||||||
|
else if (input is null) input = a;
|
||||||
|
else if (output is null) output = a;
|
||||||
|
else badUsage = true; // extra positional arg
|
||||||
|
}
|
||||||
|
|
||||||
|
if (badUsage || string.IsNullOrWhiteSpace(input) || string.IsNullOrWhiteSpace(output))
|
||||||
|
{
|
||||||
|
con.WriteLine("Usage: KillerPDF.exe --batch-resave <input.pdf|inputDir> <output.pdf|outputDir> [--log <file.csv>] [--quiet]");
|
||||||
|
exitCode = 2;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
exitCode = RunBatchResave(input!, output!, logPath, quiet, con);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
con.WriteLine("Batch mode failed: " + FlattenBatchDetail(ex.Message));
|
||||||
|
exitCode = 2;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int RunBatchResave(string input, string output, string? logPath, bool quiet, TextWriter con)
|
||||||
|
{
|
||||||
|
// Build the work list: (relative path, source, destination).
|
||||||
|
var work = new List<(string Rel, string Src, string Dst)>();
|
||||||
|
|
||||||
|
if (File.Exists(input))
|
||||||
|
{
|
||||||
|
string dst = Directory.Exists(output)
|
||||||
|
? Path.Combine(output, Path.GetFileName(input))
|
||||||
|
: output;
|
||||||
|
work.Add((Path.GetFileName(input), Path.GetFullPath(input), Path.GetFullPath(dst)));
|
||||||
|
}
|
||||||
|
else if (Directory.Exists(input))
|
||||||
|
{
|
||||||
|
string inRoot = Path.GetFullPath(input).TrimEnd('\\', '/');
|
||||||
|
string outRoot = Path.GetFullPath(output).TrimEnd('\\', '/');
|
||||||
|
// Snapshot before any output is written, so an output folder nested
|
||||||
|
// under the input tree cannot feed the enumeration.
|
||||||
|
foreach (var f in Directory.GetFiles(inRoot, "*.pdf", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
string rel = f.Substring(inRoot.Length).TrimStart('\\', '/');
|
||||||
|
work.Add((rel, f, Path.Combine(outRoot, rel)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
con.WriteLine($"Input not found: {input}");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
var log = new List<string> { "File,Status,Detail" };
|
||||||
|
int ok = 0, skip = 0, fail = 0;
|
||||||
|
|
||||||
|
foreach (var item in work)
|
||||||
|
{
|
||||||
|
string status, detail;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dstDir = Path.GetDirectoryName(item.Dst);
|
||||||
|
if (!string.IsNullOrEmpty(dstDir)) Directory.CreateDirectory(dstDir);
|
||||||
|
status = BatchResaveOne(item.Src, item.Dst, out detail);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
status = "FAIL";
|
||||||
|
detail = FlattenBatchDetail(ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status == "OK") ok++;
|
||||||
|
else if (status == "SKIP") skip++;
|
||||||
|
else fail++;
|
||||||
|
|
||||||
|
if (!quiet)
|
||||||
|
con.WriteLine(detail.Length > 0 ? $"{status} {item.Rel} ({detail})" : $"{status} {item.Rel}");
|
||||||
|
log.Add($"{BatchCsvField(item.Rel)},{status},{BatchCsvField(detail)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
con.WriteLine($"Done. {work.Count} files: {ok} OK, {skip} skipped, {fail} failed.");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(logPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllLines(logPath, log, new UTF8Encoding(false));
|
||||||
|
con.WriteLine($"Log written to {logPath}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
con.WriteLine("Could not write log: " + FlattenBatchDetail(ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fail > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resaves a single PDF through the standard save pipeline.
|
||||||
|
/// Returns "OK", "SKIP" (could not enter the plain Modify pipeline;
|
||||||
|
/// reason in <paramref name="detail"/>), or "FAIL" (opened but the
|
||||||
|
/// save itself failed - the case the harness exists to catch).
|
||||||
|
/// </summary>
|
||||||
|
private static string BatchResaveOne(string src, string dst, out string detail)
|
||||||
|
{
|
||||||
|
detail = string.Empty;
|
||||||
|
|
||||||
|
// The GUI strips encryption at open time (PDFium round-trip) before editing.
|
||||||
|
// Batch mode deliberately does not: an encryption strip is not a plain resave,
|
||||||
|
// and reporting it as one would poison the conformance comparison.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (PdfImport.PdfFileHasEncryption(src))
|
||||||
|
{
|
||||||
|
detail = "encrypted - batch mode does not strip encryption";
|
||||||
|
return "SKIP";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
detail = "unreadable: " + FlattenBatchDetail(ex.Message);
|
||||||
|
return "SKIP";
|
||||||
|
}
|
||||||
|
|
||||||
|
PdfDocument doc;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
doc = PdfReader.Open(src, PdfDocumentOpenMode.Modify);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
detail = "open failed: " + FlattenBatchDetail(ex.Message);
|
||||||
|
return "SKIP";
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Same pre-save pipeline as SaveInPlace for a document with no user edits.
|
||||||
|
PdfScrub.ScrubEmptyOutlines(doc); // #103: never write a dangling /Outlines reference
|
||||||
|
PdfScrub.ScrubDegenerateCropBoxes(doc); // never write a zero-size /CropBox (Adobe out-of-range)
|
||||||
|
PdfScrub.ScrubDeadSignatures(doc); // a rewrite voids signatures; never ship a dead one (PDF/A 6.4.3)
|
||||||
|
PdfScrub.StripLinkAnnotationBorders(doc); // link borders are stripped on every GUI save
|
||||||
|
doc.Save(dst);
|
||||||
|
doc.Close();
|
||||||
|
return "OK";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
try { doc.Close(); } catch { }
|
||||||
|
detail = "save failed: " + FlattenBatchDetail(ex.Message);
|
||||||
|
return "FAIL";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attaches to the parent terminal's console when launched from one.
|
||||||
|
// Returns TextWriter.Null when there is no parent console (e.g. double-click),
|
||||||
|
// so batch code can write unconditionally.
|
||||||
|
// internal: CliRunner shares the console attach and the CSV detail flattener.
|
||||||
|
internal static TextWriter OpenBatchConsole()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (BatchAttachConsole(BatchAttachParentProcess))
|
||||||
|
return new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true };
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return TextWriter.Null;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string FlattenBatchDetail(string? s) =>
|
||||||
|
(s ?? string.Empty).Replace("\r", " ").Replace("\n", " ").Trim();
|
||||||
|
|
||||||
|
private static string BatchCsvField(string s)
|
||||||
|
{
|
||||||
|
if (s.IndexOfAny(new[] { ',', '"', '\r', '\n' }) < 0) return s;
|
||||||
|
return "\"" + s.Replace("\"", "\"\"") + "\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,821 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Printing;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Documents;
|
||||||
|
using System.Windows.Markup;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using Docnet.Core;
|
||||||
|
using Docnet.Core.Models;
|
||||||
|
using PdfSharpCore.Drawing;
|
||||||
|
using PdfSharpCore.Pdf;
|
||||||
|
using PdfSharpCore.Pdf.IO;
|
||||||
|
using KillerPDF.Services;
|
||||||
|
// The scrubs, bitmap helpers, import helpers and PDFium interop all live in Services
|
||||||
|
// (PdfScrub.cs, BitmapHelpers.cs, PdfImport.cs, PdfiumInterop.cs; KillerUI refactor,
|
||||||
|
// 2026-07-31), called qualified below. No Features-to-Shell reaches remain in this file.
|
||||||
|
// OpenBatchConsole and FlattenBatchDetail are shared with the batch runner.
|
||||||
|
using static KillerPDF.Features.BatchRunner;
|
||||||
|
|
||||||
|
namespace KillerPDF.Features
|
||||||
|
{
|
||||||
|
// ============================================================
|
||||||
|
// Command-line interface
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// Dispatcher for every headless CLI command. Invoked from App.OnStartup
|
||||||
|
// BEFORE the single-instance mutex, so CLI runs work while a GUI instance
|
||||||
|
// is open, never forward to it, and never show a window. A launch with no
|
||||||
|
// recognized command flag falls through to the normal GUI (including the
|
||||||
|
// classic "KillerPDF.exe file.pdf" file-association open).
|
||||||
|
//
|
||||||
|
// Each command reuses the same pipeline its GUI equivalent runs - the
|
||||||
|
// merge named-destination rewrite, the pre-save scrubs, the PDFium
|
||||||
|
// decrypt, the rotation-safe rasterizer, the OCR text-layer builder - so
|
||||||
|
// CLI output is byte-for-byte the kind of file the GUI would produce.
|
||||||
|
//
|
||||||
|
// Exit codes: 0 = success, 1 = operation failed, 2 = bad usage.
|
||||||
|
//
|
||||||
|
// Console output rides on AttachConsole (GUI-subsystem exe, see
|
||||||
|
// BatchMode.cs); lines can interleave with the shell prompt. Exit codes
|
||||||
|
// are the scripting contract.
|
||||||
|
//
|
||||||
|
// Every member here is static and none of them touch the window, so this was never really a
|
||||||
|
// MainWindow partial - it just happened to be declared as one. Extracted to its own class
|
||||||
|
// 2026-07-31.
|
||||||
|
internal static class CliRunner
|
||||||
|
{
|
||||||
|
// Options that consume the next argument as their value.
|
||||||
|
private static readonly string[] CliValueOptions =
|
||||||
|
[
|
||||||
|
"--log", "--dpi", "--format", "--pages", "--printer", "--lang", "--password", "--copies",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entry point for all CLI commands. Returns false when args carry no
|
||||||
|
/// recognized command (normal GUI launch); otherwise runs the command
|
||||||
|
/// and returns true with the process exit code set.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool TryRunCli(string[] args, out int exitCode)
|
||||||
|
{
|
||||||
|
exitCode = 0;
|
||||||
|
if (args is null || args.Length == 0) return false;
|
||||||
|
|
||||||
|
// The validation resave keeps its dedicated runner in BatchMode.cs.
|
||||||
|
if (args.Any(a => Eq(a, "--batch-resave")))
|
||||||
|
return BatchRunner.TryRunBatch(args, out exitCode);
|
||||||
|
|
||||||
|
string? command = args.FirstOrDefault(a =>
|
||||||
|
Eq(a, "--help") || Eq(a, "-h") || Eq(a, "/?") ||
|
||||||
|
Eq(a, "--version") || Eq(a, "-v") ||
|
||||||
|
Eq(a, "--merge") || Eq(a, "--extract-pages") || Eq(a, "--split") ||
|
||||||
|
Eq(a, "--decrypt") || Eq(a, "--to-image") || Eq(a, "--flatten") ||
|
||||||
|
Eq(a, "--print") || Eq(a, "--ocr"));
|
||||||
|
if (command is null) return false;
|
||||||
|
|
||||||
|
var con = OpenBatchConsole();
|
||||||
|
var (positionals, options) = ParseCliArgs(args, command);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
switch (command.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "--help":
|
||||||
|
case "-h":
|
||||||
|
case "/?":
|
||||||
|
con.WriteLine(CliHelpText());
|
||||||
|
break;
|
||||||
|
case "--version":
|
||||||
|
case "-v":
|
||||||
|
con.WriteLine(Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "unknown");
|
||||||
|
break;
|
||||||
|
case "--merge":
|
||||||
|
exitCode = CliMerge(positionals, con);
|
||||||
|
break;
|
||||||
|
case "--extract-pages":
|
||||||
|
exitCode = CliExtractPages(positionals, con);
|
||||||
|
break;
|
||||||
|
case "--split":
|
||||||
|
exitCode = CliSplit(positionals, con);
|
||||||
|
break;
|
||||||
|
case "--decrypt":
|
||||||
|
exitCode = CliDecrypt(positionals, options, con);
|
||||||
|
break;
|
||||||
|
case "--to-image":
|
||||||
|
exitCode = CliToImage(positionals, options, con);
|
||||||
|
break;
|
||||||
|
case "--flatten":
|
||||||
|
exitCode = CliFlatten(positionals, options, con);
|
||||||
|
break;
|
||||||
|
case "--print":
|
||||||
|
exitCode = CliPrint(positionals, options, con);
|
||||||
|
break;
|
||||||
|
case "--ocr":
|
||||||
|
exitCode = CliOcr(positionals, options, con);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
con.WriteLine("Error: " + FlattenBatchDetail(ex.Message));
|
||||||
|
exitCode = 1;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
App.CleanupSessionTemps(); // drop any decrypt/rotation temps the run created
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Eq(string a, string b) =>
|
||||||
|
string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static string CliHelpText() => string.Join(Environment.NewLine,
|
||||||
|
[
|
||||||
|
"KillerPDF " + (Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "") + " - command line usage",
|
||||||
|
"",
|
||||||
|
" KillerPDF.exe <file.pdf> open in the app",
|
||||||
|
" KillerPDF.exe --version | -v print version",
|
||||||
|
" KillerPDF.exe --help | -h | /? this text",
|
||||||
|
"",
|
||||||
|
" --merge <out.pdf> <in1> <in2> ... merge PDFs (and images) into one PDF",
|
||||||
|
" --extract-pages <in.pdf> <pages> <out.pdf>",
|
||||||
|
" pull pages into a new PDF (pages like 1-3,5,9-12)",
|
||||||
|
" --split <in.pdf> <outDir> write one PDF per page",
|
||||||
|
" --decrypt <in.pdf> <out.pdf> [--password <p>]",
|
||||||
|
" remove encryption (lossless when possible)",
|
||||||
|
" --to-image <in.pdf> <outDir> [--dpi <n>] [--format png|jpg] [--pages <range>] [--transparent]",
|
||||||
|
" render pages to images (default 150 dpi, png;",
|
||||||
|
" background composites to white unless --transparent, png only)",
|
||||||
|
" --flatten <in.pdf> <out.pdf> [--dpi <n>] rasterize into an uneditable PDF (default 150 dpi)",
|
||||||
|
" --print <in.pdf> [--printer <name>] [--pages <range>] [--copies <n>]",
|
||||||
|
" print silently (default printer if none named)",
|
||||||
|
" --ocr <in.pdf> <out.pdf> [--lang <code>] add an invisible searchable text layer (default eng;",
|
||||||
|
" other languages download on first use)",
|
||||||
|
" --batch-resave <in> <out> [--log <f.csv>] [--quiet]",
|
||||||
|
" resave a file or tree through the standard",
|
||||||
|
" open/save pipeline (validation harness)",
|
||||||
|
"",
|
||||||
|
"Exit codes: 0 success, 1 operation failed, 2 bad usage.",
|
||||||
|
"Runs headless and works while the KillerPDF window is open.",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Splits args into positionals (everything after the command flag that
|
||||||
|
/// is not an option) and an option dictionary (case-insensitive keys).
|
||||||
|
/// </summary>
|
||||||
|
private static (List<string> Positionals, Dictionary<string, string> Options)
|
||||||
|
ParseCliArgs(string[] args, string command)
|
||||||
|
{
|
||||||
|
var positionals = new List<string>();
|
||||||
|
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
int start = Array.FindIndex(args, a => Eq(a, command)) + 1;
|
||||||
|
for (int i = start; i < args.Length; i++)
|
||||||
|
{
|
||||||
|
var a = args[i];
|
||||||
|
if (a.StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (CliValueOptions.Any(o => Eq(o, a)) && i + 1 < args.Length)
|
||||||
|
options[a] = args[++i];
|
||||||
|
else
|
||||||
|
options[a] = string.Empty;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
positionals.Add(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (positionals, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a 1-based page range spec like "1-3,5,9-12" into sorted,
|
||||||
|
/// distinct 0-based indices. Returns null with a message in error when
|
||||||
|
/// the spec is malformed or out of range.
|
||||||
|
/// </summary>
|
||||||
|
// internal, not private: FileOperations still calls these two. They are page-range parsing
|
||||||
|
// and JPEG encoding, neither of which is really CLI-specific - they want a home in
|
||||||
|
// Services/ eventually.
|
||||||
|
internal static List<int>? CliParsePageRange(string spec, int pageCount, out string error)
|
||||||
|
{
|
||||||
|
error = string.Empty;
|
||||||
|
var pages = new SortedSet<int>();
|
||||||
|
foreach (var rawPart in spec.Split(','))
|
||||||
|
{
|
||||||
|
var part = rawPart.Trim();
|
||||||
|
if (part.Length == 0) continue;
|
||||||
|
int a, b;
|
||||||
|
int dash = part.IndexOf('-');
|
||||||
|
if (dash > 0)
|
||||||
|
{
|
||||||
|
if (!int.TryParse(part[..dash].Trim(), out a) ||
|
||||||
|
!int.TryParse(part[(dash + 1)..].Trim(), out b))
|
||||||
|
{ error = $"Bad page range: \"{part}\""; return null; }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!int.TryParse(part, out a)) { error = $"Bad page number: \"{part}\""; return null; }
|
||||||
|
b = a;
|
||||||
|
}
|
||||||
|
if (a > b) (a, b) = (b, a);
|
||||||
|
if (a < 1 || b > pageCount)
|
||||||
|
{ error = $"Pages {part} out of range - the document has {pageCount} pages"; return null; }
|
||||||
|
for (int p = a; p <= b; p++) pages.Add(p - 1);
|
||||||
|
}
|
||||||
|
if (pages.Count == 0) { error = "Empty page range"; return null; }
|
||||||
|
return [.. pages];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// --merge <out.pdf> <in1> <in2> ...
|
||||||
|
// ============================================================
|
||||||
|
// Mirrors the GUI merge (FileOperations.cs Merge_Click): per source PDF,
|
||||||
|
// harvest named destinations from a ReadOnly open, copy pages from an
|
||||||
|
// Import open, then rewrite named-destination links against the page
|
||||||
|
// offset. Image inputs go through the same importer the GUI drop
|
||||||
|
// pipeline uses (ImportAndZip.cs).
|
||||||
|
private static int CliMerge(List<string> pos, TextWriter con)
|
||||||
|
{
|
||||||
|
if (pos.Count < 3)
|
||||||
|
{
|
||||||
|
con.WriteLine("Usage: KillerPDF.exe --merge <out.pdf> <in1.pdf> <in2.pdf> ...");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
string outPath = Path.GetFullPath(pos[0]);
|
||||||
|
var inputs = pos.Skip(1).Select(Path.GetFullPath).ToList();
|
||||||
|
|
||||||
|
foreach (var f in inputs)
|
||||||
|
{
|
||||||
|
if (!File.Exists(f)) { con.WriteLine($"Input not found: {f}"); return 2; }
|
||||||
|
if (string.Equals(f, outPath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{ con.WriteLine("Output file cannot also be an input."); return 2; }
|
||||||
|
}
|
||||||
|
|
||||||
|
using var outPdf = new PdfDocument();
|
||||||
|
foreach (var f in inputs)
|
||||||
|
{
|
||||||
|
if (PdfImport.IsPdfPath(f))
|
||||||
|
{
|
||||||
|
int pageOffset = outPdf.PageCount;
|
||||||
|
Dictionary<string, int> namedDestMap;
|
||||||
|
using (var srcRead = PdfReader.Open(f, PdfDocumentOpenMode.ReadOnly))
|
||||||
|
namedDestMap = PdfImport.BuildNamedDestMap(srcRead);
|
||||||
|
using var src = PdfReader.Open(f, PdfDocumentOpenMode.Import);
|
||||||
|
for (int i = 0; i < src.PageCount; i++)
|
||||||
|
outPdf.AddPage(src.Pages[i]);
|
||||||
|
if (namedDestMap.Count > 0)
|
||||||
|
PdfImport.RewriteNamedDestLinks(outPdf, pageOffset, namedDestMap);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
PdfImport.AddImagePagesFromFile(outPdf, f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PdfScrub.ScrubEmptyOutlines(outPdf);
|
||||||
|
PdfScrub.ScrubDegenerateCropBoxes(outPdf);
|
||||||
|
CliEnsureParentDir(outPath);
|
||||||
|
outPdf.Save(outPath);
|
||||||
|
con.WriteLine($"Merged {inputs.Count} files ({outPdf.PageCount} pages) -> {outPath}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// --extract-pages <in.pdf> <range> <out.pdf>
|
||||||
|
// ============================================================
|
||||||
|
// Same primitive as the GUI extract (PageOperations.cs Split_Click):
|
||||||
|
// Import-mode open, AddPage per selected index, save a fresh document.
|
||||||
|
private static int CliExtractPages(List<string> pos, TextWriter con)
|
||||||
|
{
|
||||||
|
if (pos.Count != 3)
|
||||||
|
{
|
||||||
|
con.WriteLine("Usage: KillerPDF.exe --extract-pages <in.pdf> <pages> <out.pdf> (pages like 1-3,5,9-12)");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
string inPath = Path.GetFullPath(pos[0]), spec = pos[1], outPath = Path.GetFullPath(pos[2]);
|
||||||
|
if (!File.Exists(inPath)) { con.WriteLine($"Input not found: {inPath}"); return 2; }
|
||||||
|
|
||||||
|
using var importDoc = PdfReader.Open(inPath, PdfDocumentOpenMode.Import);
|
||||||
|
var indices = CliParsePageRange(spec, importDoc.PageCount, out string err);
|
||||||
|
if (indices is null) { con.WriteLine(err); return 2; }
|
||||||
|
|
||||||
|
using var newDoc = new PdfDocument();
|
||||||
|
foreach (var idx in indices)
|
||||||
|
newDoc.AddPage(importDoc.Pages[idx]);
|
||||||
|
PdfScrub.ScrubEmptyOutlines(newDoc);
|
||||||
|
PdfScrub.ScrubDegenerateCropBoxes(newDoc);
|
||||||
|
CliEnsureParentDir(outPath);
|
||||||
|
newDoc.Save(outPath);
|
||||||
|
con.WriteLine($"Extracted {indices.Count} pages -> {outPath}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// --split <in.pdf> <outDir>
|
||||||
|
// ============================================================
|
||||||
|
private static int CliSplit(List<string> pos, TextWriter con)
|
||||||
|
{
|
||||||
|
if (pos.Count != 2)
|
||||||
|
{
|
||||||
|
con.WriteLine("Usage: KillerPDF.exe --split <in.pdf> <outputFolder>");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
string inPath = Path.GetFullPath(pos[0]), outDir = Path.GetFullPath(pos[1]);
|
||||||
|
if (!File.Exists(inPath)) { con.WriteLine($"Input not found: {inPath}"); return 2; }
|
||||||
|
Directory.CreateDirectory(outDir);
|
||||||
|
|
||||||
|
using var importDoc = PdfReader.Open(inPath, PdfDocumentOpenMode.Import);
|
||||||
|
string baseName = Path.GetFileNameWithoutExtension(inPath);
|
||||||
|
int digits = Math.Max(3, importDoc.PageCount.ToString().Length);
|
||||||
|
for (int i = 0; i < importDoc.PageCount; i++)
|
||||||
|
{
|
||||||
|
using var single = new PdfDocument();
|
||||||
|
single.AddPage(importDoc.Pages[i]);
|
||||||
|
PdfScrub.ScrubEmptyOutlines(single);
|
||||||
|
PdfScrub.ScrubDegenerateCropBoxes(single);
|
||||||
|
single.Save(Path.Combine(outDir, $"{baseName}-page-{(i + 1).ToString().PadLeft(digits, '0')}.pdf"));
|
||||||
|
}
|
||||||
|
con.WriteLine($"Split {importDoc.PageCount} pages into {outDir}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// --decrypt <in.pdf> <out.pdf> [--password <p>]
|
||||||
|
// ============================================================
|
||||||
|
// Without a password: the same lossless PDFium strip the GUI uses at
|
||||||
|
// open time (owner/permissions encryption), with the Import-rebuild
|
||||||
|
// fallback. With a password: PdfSharpCore opens with the password and
|
||||||
|
// saves a decrypted copy, the same sequence as the GUI password path.
|
||||||
|
private static int CliDecrypt(List<string> pos, Dictionary<string, string> options, TextWriter con)
|
||||||
|
{
|
||||||
|
if (pos.Count != 2)
|
||||||
|
{
|
||||||
|
con.WriteLine("Usage: KillerPDF.exe --decrypt <in.pdf> <out.pdf> [--password <password>]");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
string inPath = Path.GetFullPath(pos[0]), outPath = Path.GetFullPath(pos[1]);
|
||||||
|
if (!File.Exists(inPath)) { con.WriteLine($"Input not found: {inPath}"); return 2; }
|
||||||
|
CliEnsureParentDir(outPath);
|
||||||
|
|
||||||
|
options.TryGetValue("--password", out string? password);
|
||||||
|
if (!string.IsNullOrEmpty(password))
|
||||||
|
{
|
||||||
|
using var doc = PdfReader.Open(inPath, password!, PdfDocumentOpenMode.Modify);
|
||||||
|
PdfScrub.ScrubEmptyOutlines(doc);
|
||||||
|
PdfScrub.ScrubDegenerateCropBoxes(doc);
|
||||||
|
doc.Save(outPath);
|
||||||
|
con.WriteLine($"Decrypted -> {outPath}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PdfiumInterop.TryPdfiumStripEncryption(inPath, outPath))
|
||||||
|
{
|
||||||
|
con.WriteLine($"Decrypted (lossless) -> {outPath}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (PdfImport.TryImportRepairToPath(inPath, outPath))
|
||||||
|
{
|
||||||
|
con.WriteLine($"Decrypted via page rebuild -> {outPath} (bookmarks/forms may be dropped)");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
con.WriteLine("Could not decrypt. If the file needs a password to open, pass --password.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Shared rasterization prep
|
||||||
|
// ============================================================
|
||||||
|
// PDFium sizes its bitmap from the un-rotated MediaBox, so pages with
|
||||||
|
// /Rotate 90/270 clip if rendered directly (same reason TempReload
|
||||||
|
// strips rotations app-wide). Prep: decrypt if needed, capture per-page
|
||||||
|
// /Rotate + point dims, strip rotations to a temp, and let callers
|
||||||
|
// rotate the pixel buffers afterward (BitmapHelpers.RotateBitmap).
|
||||||
|
// Falls back to rendering the file as-is when PdfSharpCore cannot open
|
||||||
|
// it (rare parser gaps PDFium tolerates); callers then derive
|
||||||
|
// dimensions from the rendered pixels.
|
||||||
|
private static (string RenderPath, int[]? Rotations, (double WPt, double HPt)[]? Dims)
|
||||||
|
CliPrepareRenderSource(string inPath, string? password, TextWriter con)
|
||||||
|
{
|
||||||
|
string workPath = inPath;
|
||||||
|
if (PdfImport.PdfFileHasEncryption(inPath))
|
||||||
|
{
|
||||||
|
var dec = App.MakeTempFile("clidec");
|
||||||
|
if (!string.IsNullOrEmpty(password))
|
||||||
|
{
|
||||||
|
using var pdoc = PdfReader.Open(inPath, password!, PdfDocumentOpenMode.Modify);
|
||||||
|
pdoc.Save(dec);
|
||||||
|
}
|
||||||
|
else if (!PdfiumInterop.TryPdfiumStripEncryption(inPath, dec) && !PdfImport.TryImportRepairToPath(inPath, dec))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"File is encrypted and could not be unlocked - pass --password if it needs one.");
|
||||||
|
}
|
||||||
|
workPath = dec;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = PdfReader.Open(workPath, PdfDocumentOpenMode.Modify);
|
||||||
|
var rotations = new int[doc.PageCount];
|
||||||
|
var dims = new (double WPt, double HPt)[doc.PageCount];
|
||||||
|
bool anyRot = false;
|
||||||
|
for (int i = 0; i < doc.PageCount; i++)
|
||||||
|
{
|
||||||
|
var p = doc.Pages[i];
|
||||||
|
rotations[i] = ((p.Rotate % 360) + 360) % 360;
|
||||||
|
dims[i] = (p.Width.Point, p.Height.Point);
|
||||||
|
if (rotations[i] != 0) { anyRot = true; p.Rotate = 0; }
|
||||||
|
}
|
||||||
|
if (!anyRot) return (workPath, rotations, dims);
|
||||||
|
|
||||||
|
var renderTemp = App.MakeTempFile("clirender");
|
||||||
|
PdfScrub.ScrubEmptyOutlines(doc);
|
||||||
|
PdfScrub.ScrubDegenerateCropBoxes(doc);
|
||||||
|
doc.Save(renderTemp);
|
||||||
|
return (renderTemp, rotations, dims);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
con.WriteLine("Note: structure parse failed (" + FlattenBatchDetail(ex.Message) +
|
||||||
|
") - rendering as-is; rotated pages may clip.");
|
||||||
|
return (workPath, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both encoders live in Services/BitmapHelpers.cs (RenderToPng, and EncodeJpeg - which
|
||||||
|
// was born here as CliEncodeJpeg when --to-image needed a JPEG encoder).
|
||||||
|
|
||||||
|
private static void CliEnsureParentDir(string path)
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(path);
|
||||||
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double CliParseDpi(Dictionary<string, string> options, double fallback)
|
||||||
|
{
|
||||||
|
if (options.TryGetValue("--dpi", out var s) &&
|
||||||
|
double.TryParse(s, out double d) && d >= 24 && d <= 1200)
|
||||||
|
return d;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// --to-image <in.pdf> <outDir> [--dpi n] [--format png|jpg] [--pages range] [--transparent]
|
||||||
|
// ============================================================
|
||||||
|
// PDFium leaves unpainted background pixels as BGRA 0,0,0,0. Encoders
|
||||||
|
// that drop alpha (JPEG) then show them BLACK, and PNG/flatten output
|
||||||
|
// carries a useless full-page alpha channel (issue #148, Ryokoxx).
|
||||||
|
// Default is now composite-over-white via Docnet's transparency
|
||||||
|
// remover; --transparent keeps the raw alpha for PNG output.
|
||||||
|
private static int CliToImage(List<string> pos, Dictionary<string, string> options, TextWriter con)
|
||||||
|
{
|
||||||
|
if (pos.Count != 2)
|
||||||
|
{
|
||||||
|
con.WriteLine("Usage: KillerPDF.exe --to-image <in.pdf> <outputFolder> [--dpi <n>] [--format png|jpg] [--pages <range>] [--transparent]");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
string inPath = Path.GetFullPath(pos[0]), outDir = Path.GetFullPath(pos[1]);
|
||||||
|
if (!File.Exists(inPath)) { con.WriteLine($"Input not found: {inPath}"); return 2; }
|
||||||
|
double dpi = CliParseDpi(options, 150);
|
||||||
|
options.TryGetValue("--format", out var fmtRaw);
|
||||||
|
string fmt = (fmtRaw ?? "png").ToLowerInvariant();
|
||||||
|
if (fmt == "jpeg") fmt = "jpg";
|
||||||
|
if (fmt != "png" && fmt != "jpg") { con.WriteLine("--format must be png or jpg"); return 2; }
|
||||||
|
// JPEG has no alpha channel, so --transparent only means anything for png.
|
||||||
|
bool transparent = fmt == "png" && options.ContainsKey("--transparent");
|
||||||
|
Directory.CreateDirectory(outDir);
|
||||||
|
|
||||||
|
options.TryGetValue("--password", out var password);
|
||||||
|
var (renderPath, rotations, _) = CliPrepareRenderSource(inPath, password, con);
|
||||||
|
|
||||||
|
using var dr = DocLib.Instance.GetDocReader(renderPath, new PageDimensions(dpi / 72.0));
|
||||||
|
int pageCount = dr.GetPageCount();
|
||||||
|
|
||||||
|
List<int> selected;
|
||||||
|
if (options.TryGetValue("--pages", out var rangeSpec))
|
||||||
|
{
|
||||||
|
var parsed = CliParsePageRange(rangeSpec, pageCount, out string err);
|
||||||
|
if (parsed is null) { con.WriteLine(err); return 2; }
|
||||||
|
selected = parsed;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
selected = [.. Enumerable.Range(0, pageCount)];
|
||||||
|
}
|
||||||
|
|
||||||
|
string baseName = Path.GetFileNameWithoutExtension(inPath);
|
||||||
|
int digits = Math.Max(3, pageCount.ToString().Length);
|
||||||
|
foreach (var idx in selected)
|
||||||
|
{
|
||||||
|
byte[] raw; int w, h;
|
||||||
|
using (var pr = dr.GetPageReader(idx))
|
||||||
|
{
|
||||||
|
w = pr.GetPageWidth();
|
||||||
|
h = pr.GetPageHeight();
|
||||||
|
raw = KillerPDF.Services.PdfiumInterop.RenderPageWithAnnotations(
|
||||||
|
renderPath, idx, w, h, transparent)
|
||||||
|
?? (transparent
|
||||||
|
? pr.GetImage()
|
||||||
|
: pr.GetImage(new Docnet.Core.Converters.NaiveTransparencyRemover()));
|
||||||
|
}
|
||||||
|
int rot = rotations != null && idx < rotations.Length ? rotations[idx] : 0;
|
||||||
|
if (rot != 0) (raw, w, h) = BitmapHelpers.RotateBitmap(raw, w, h, rot);
|
||||||
|
var bytes = fmt == "png" ? BitmapHelpers.RenderToPng(raw, w, h, dpi) : BitmapHelpers.EncodeJpeg(raw, w, h, dpi);
|
||||||
|
var name = $"{baseName}-page-{(idx + 1).ToString().PadLeft(digits, '0')}.{fmt}";
|
||||||
|
File.WriteAllBytes(Path.Combine(outDir, name), bytes);
|
||||||
|
}
|
||||||
|
con.WriteLine($"Rendered {selected.Count} pages at {dpi:0} dpi ({fmt}) into {outDir}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// --flatten <in.pdf> <out.pdf> [--dpi n]
|
||||||
|
// ============================================================
|
||||||
|
// Same rasterize-and-rebuild the GUI's Save Flattened runs (150 dpi
|
||||||
|
// default, PNG-embedded pages sized in points), plus the rotation
|
||||||
|
// handling the GUI gets for free from its normalized working copy.
|
||||||
|
private static int CliFlatten(List<string> pos, Dictionary<string, string> options, TextWriter con)
|
||||||
|
{
|
||||||
|
if (pos.Count != 2)
|
||||||
|
{
|
||||||
|
con.WriteLine("Usage: KillerPDF.exe --flatten <in.pdf> <out.pdf> [--dpi <n>]");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
string inPath = Path.GetFullPath(pos[0]), outPath = Path.GetFullPath(pos[1]);
|
||||||
|
if (!File.Exists(inPath)) { con.WriteLine($"Input not found: {inPath}"); return 2; }
|
||||||
|
double dpi = CliParseDpi(options, 150);
|
||||||
|
options.TryGetValue("--password", out var password);
|
||||||
|
|
||||||
|
var (renderPath, rotations, dims) = CliPrepareRenderSource(inPath, password, con);
|
||||||
|
|
||||||
|
using var dr = DocLib.Instance.GetDocReader(renderPath, new PageDimensions(dpi / 72.0));
|
||||||
|
int pageCount = dr.GetPageCount();
|
||||||
|
|
||||||
|
using var outDoc = new PdfDocument();
|
||||||
|
for (int i = 0; i < pageCount; i++)
|
||||||
|
{
|
||||||
|
byte[] raw; int w, h;
|
||||||
|
using (var pr = dr.GetPageReader(i))
|
||||||
|
{
|
||||||
|
// Composite over white (#148): keeps the /SMask alpha channel out
|
||||||
|
// of the rebuilt page images entirely.
|
||||||
|
// #141: WithAnnotations, or the rebuild drops the file's own markup.
|
||||||
|
w = pr.GetPageWidth();
|
||||||
|
h = pr.GetPageHeight();
|
||||||
|
raw = KillerPDF.Services.PdfiumInterop.RenderPageWithAnnotations(renderPath, i, w, h)
|
||||||
|
?? pr.GetImage(new Docnet.Core.Converters.NaiveTransparencyRemover());
|
||||||
|
}
|
||||||
|
int rot = rotations != null && i < rotations.Length ? rotations[i] : 0;
|
||||||
|
if (rot != 0) (raw, w, h) = BitmapHelpers.RotateBitmap(raw, w, h, rot);
|
||||||
|
var png = BitmapHelpers.RenderToPng(raw, w, h);
|
||||||
|
|
||||||
|
double wPt, hPt;
|
||||||
|
if (dims != null && i < dims.Length)
|
||||||
|
{
|
||||||
|
// Page keeps its point size; swap for the viewed orientation.
|
||||||
|
bool swap = rot == 90 || rot == 270;
|
||||||
|
wPt = swap ? dims[i].HPt : dims[i].WPt;
|
||||||
|
hPt = swap ? dims[i].WPt : dims[i].HPt;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
wPt = w * 72.0 / dpi;
|
||||||
|
hPt = h * 72.0 / dpi;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newPage = outDoc.AddPage();
|
||||||
|
newPage.Width = XUnit.FromPoint(wPt);
|
||||||
|
newPage.Height = XUnit.FromPoint(hPt);
|
||||||
|
using var xi = XImage.FromStream(() => new MemoryStream(png));
|
||||||
|
using var gfx = XGraphics.FromPdfPage(newPage);
|
||||||
|
gfx.DrawImage(xi, 0, 0, newPage.Width.Point, newPage.Height.Point);
|
||||||
|
}
|
||||||
|
CliEnsureParentDir(outPath);
|
||||||
|
outDoc.Save(outPath);
|
||||||
|
con.WriteLine($"Flattened {pageCount} pages at {dpi:0} dpi -> {outPath}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// --print <in.pdf> [--printer name] [--pages range] [--copies n]
|
||||||
|
// ============================================================
|
||||||
|
// Slimmed headless version of the GUI print spool: rasterize at 300
|
||||||
|
// dpi (the GUI's print resolution), fit-scale each page centered on
|
||||||
|
// the printable area, build a FixedDocument, and write it to the
|
||||||
|
// queue via XPS. Copies replicate the page sequence (ticket CopyCount
|
||||||
|
// is unreliable across drivers - same reason the GUI does this, #83).
|
||||||
|
private static int CliPrint(List<string> pos, Dictionary<string, string> options, TextWriter con)
|
||||||
|
{
|
||||||
|
if (pos.Count != 1)
|
||||||
|
{
|
||||||
|
con.WriteLine("Usage: KillerPDF.exe --print <in.pdf> [--printer <name>] [--pages <range>] [--copies <n>]");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
string inPath = Path.GetFullPath(pos[0]);
|
||||||
|
if (!File.Exists(inPath)) { con.WriteLine($"Input not found: {inPath}"); return 2; }
|
||||||
|
|
||||||
|
int copies = 1;
|
||||||
|
if (options.TryGetValue("--copies", out var copiesRaw) &&
|
||||||
|
(!int.TryParse(copiesRaw, out copies) || copies < 1 || copies > 99))
|
||||||
|
{ con.WriteLine("--copies must be 1-99"); return 2; }
|
||||||
|
|
||||||
|
options.TryGetValue("--password", out var password);
|
||||||
|
var (renderPath, rotations, _) = CliPrepareRenderSource(inPath, password, con);
|
||||||
|
|
||||||
|
// Resolve the print queue. Match --printer against FullName,
|
||||||
|
// exact first then substring, both case-insensitive.
|
||||||
|
using var server = new LocalPrintServer();
|
||||||
|
PrintQueue? queue = null;
|
||||||
|
if (options.TryGetValue("--printer", out var printerName) && !string.IsNullOrWhiteSpace(printerName))
|
||||||
|
{
|
||||||
|
var queues = server.GetPrintQueues(
|
||||||
|
[EnumeratedPrintQueueTypes.Local, EnumeratedPrintQueueTypes.Connections]).ToList();
|
||||||
|
queue = queues.FirstOrDefault(q =>
|
||||||
|
string.Equals(q.FullName, printerName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? queues.FirstOrDefault(q =>
|
||||||
|
q.FullName.IndexOf(printerName, StringComparison.OrdinalIgnoreCase) >= 0);
|
||||||
|
if (queue is null)
|
||||||
|
{
|
||||||
|
con.WriteLine($"Printer not found: {printerName}. Available:");
|
||||||
|
foreach (var q in queues) con.WriteLine(" " + q.FullName);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
queue = LocalPrintServer.GetDefaultPrintQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rasterize the selected pages at 300 dpi, rotation-corrected.
|
||||||
|
var bitmaps = new List<(BitmapSource Bs, int W, int H)>();
|
||||||
|
List<int> selected;
|
||||||
|
using (var dr = DocLib.Instance.GetDocReader(renderPath, new PageDimensions(300.0 / 72.0)))
|
||||||
|
{
|
||||||
|
int pageCount = dr.GetPageCount();
|
||||||
|
if (options.TryGetValue("--pages", out var rangeSpec))
|
||||||
|
{
|
||||||
|
var parsed = CliParsePageRange(rangeSpec, pageCount, out string err);
|
||||||
|
if (parsed is null) { con.WriteLine(err); return 2; }
|
||||||
|
selected = parsed;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
selected = [.. Enumerable.Range(0, pageCount)];
|
||||||
|
}
|
||||||
|
foreach (var idx in selected)
|
||||||
|
{
|
||||||
|
byte[] raw; int w, h;
|
||||||
|
using (var pr = dr.GetPageReader(idx))
|
||||||
|
{
|
||||||
|
w = pr.GetPageWidth();
|
||||||
|
h = pr.GetPageHeight();
|
||||||
|
raw = KillerPDF.Services.PdfiumInterop.RenderPageWithAnnotations(renderPath, idx, w, h)
|
||||||
|
?? pr.GetImage(); // #141
|
||||||
|
}
|
||||||
|
int rot = rotations != null && idx < rotations.Length ? rotations[idx] : 0;
|
||||||
|
if (rot != 0) (raw, w, h) = BitmapHelpers.RotateBitmap(raw, w, h, rot);
|
||||||
|
var bs = BitmapSource.Create(w, h, 96, 96, PixelFormats.Bgra32, null, raw, w * 4);
|
||||||
|
bs.Freeze();
|
||||||
|
bitmaps.Add((bs, w, h));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orient the sheet to the majority of the selected pages.
|
||||||
|
bool landscape = bitmaps.Count(b => b.W > b.H) * 2 > bitmaps.Count;
|
||||||
|
|
||||||
|
var pd = new System.Windows.Controls.PrintDialog { PrintQueue = queue };
|
||||||
|
var ticket = pd.PrintTicket;
|
||||||
|
ticket.CopyCount = 1;
|
||||||
|
ticket.PageOrientation = landscape ? PageOrientation.Landscape : PageOrientation.Portrait;
|
||||||
|
pd.PrintTicket = ticket;
|
||||||
|
double aw = pd.PrintableAreaWidth, ah = pd.PrintableAreaHeight;
|
||||||
|
if (landscape && ah > aw) (aw, ah) = (ah, aw);
|
||||||
|
|
||||||
|
var fixedDoc = new FixedDocument();
|
||||||
|
for (int c = 0; c < copies; c++)
|
||||||
|
{
|
||||||
|
foreach (var (bs, w, h) in bitmaps)
|
||||||
|
{
|
||||||
|
double wDip = w * 96.0 / 300.0, hDip = h * 96.0 / 300.0;
|
||||||
|
double s = Math.Min(aw / wDip, ah / hDip);
|
||||||
|
double sw = wDip * s, sh = hDip * s;
|
||||||
|
var img = new System.Windows.Controls.Image { Source = bs, Width = sw, Height = sh };
|
||||||
|
var fp = new FixedPage { Width = aw, Height = ah };
|
||||||
|
FixedPage.SetLeft(img, (aw - sw) / 2);
|
||||||
|
FixedPage.SetTop(img, (ah - sh) / 2);
|
||||||
|
fp.Children.Add(img);
|
||||||
|
fp.Measure(new Size(aw, ah));
|
||||||
|
fp.Arrange(new Rect(0, 0, aw, ah));
|
||||||
|
fp.UpdateLayout();
|
||||||
|
var pc = new PageContent();
|
||||||
|
((IAddChild)pc).AddChild(fp);
|
||||||
|
fixedDoc.Pages.Add(pc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the FixedDocument (not its paginator) - see PrintPreviewWindow
|
||||||
|
// DoPrint for why. Synchronous Write is fine headless.
|
||||||
|
var writer = PrintQueue.CreateXpsDocumentWriter(queue);
|
||||||
|
writer.Write(fixedDoc, ticket);
|
||||||
|
con.WriteLine($"Sent {selected.Count} pages x{copies} to \"{queue.FullName}\".");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// --ocr <in.pdf> <out.pdf> [--lang code]
|
||||||
|
// ============================================================
|
||||||
|
// Reuses the GUI's searchable-PDF core (OcrController.BuildSearchablePdf):
|
||||||
|
// Docnet render, Tesseract per page, invisible text drawn over each
|
||||||
|
// word. The GUI's model-download gate is dialog-driven, so the CLI
|
||||||
|
// has its own silent equivalent honoring the OcrHighQuality setting.
|
||||||
|
private static int CliOcr(List<string> pos, Dictionary<string, string> options, TextWriter con)
|
||||||
|
{
|
||||||
|
if (pos.Count != 2)
|
||||||
|
{
|
||||||
|
con.WriteLine("Usage: KillerPDF.exe --ocr <in.pdf> <out.pdf> [--lang <code>] (default eng)");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
string inPath = Path.GetFullPath(pos[0]), outPath = Path.GetFullPath(pos[1]);
|
||||||
|
if (!File.Exists(inPath)) { con.WriteLine($"Input not found: {inPath}"); return 2; }
|
||||||
|
options.TryGetValue("--lang", out var langRaw);
|
||||||
|
string lang = string.IsNullOrWhiteSpace(langRaw) ? "eng" : langRaw!.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
if (!CliEnsureOcrLanguage(lang, con)) return 1;
|
||||||
|
|
||||||
|
options.TryGetValue("--password", out var password);
|
||||||
|
var (srcForOcr, rotations, _) = CliPrepareRenderSource(inPath, password, con);
|
||||||
|
|
||||||
|
CliEnsureParentDir(outPath);
|
||||||
|
var (pages, words) = OcrController.BuildSearchablePdf(srcForOcr, outPath,
|
||||||
|
(i, n) => { if (i == 1 || i == n || i % 10 == 0) con.WriteLine($"OCR page {i}/{n}"); },
|
||||||
|
CancellationToken.None, lang);
|
||||||
|
|
||||||
|
// The render source had /Rotate stripped; put the angles back on the
|
||||||
|
// output so rotated pages still display rotated. Content and text
|
||||||
|
// layer share page space, so they stay aligned.
|
||||||
|
if (rotations != null && rotations.Any(r => r != 0))
|
||||||
|
{
|
||||||
|
using var outDoc = PdfReader.Open(outPath, PdfDocumentOpenMode.Modify);
|
||||||
|
for (int i = 0; i < outDoc.PageCount && i < rotations.Length; i++)
|
||||||
|
if (rotations[i] != 0) outDoc.Pages[i].Rotate = rotations[i];
|
||||||
|
PdfScrub.ScrubEmptyOutlines(outDoc);
|
||||||
|
PdfScrub.ScrubDegenerateCropBoxes(outDoc);
|
||||||
|
outDoc.Save(outPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
con.WriteLine($"OCR complete: {pages} pages, {words} words -> {outPath}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Silent equivalent of the GUI's model-download gate: nothing is
|
||||||
|
/// bundled - every language model streams from the tessdata repos on
|
||||||
|
/// first use, honoring the app's High Quality setting, with the same
|
||||||
|
/// .part-then-move atomicity. Runs the download on the thread pool -
|
||||||
|
/// OnStartup's dispatcher is not pumping, so awaiting here directly
|
||||||
|
/// would deadlock on the captured WPF context.
|
||||||
|
/// </summary>
|
||||||
|
private static bool CliEnsureOcrLanguage(string lang, TextWriter con)
|
||||||
|
{
|
||||||
|
OcrNativeBootstrap.EnsureLanguageData();
|
||||||
|
var dest = Path.Combine(OcrNativeBootstrap.TessDataDir, lang + ".traineddata");
|
||||||
|
if (File.Exists(dest)) return true;
|
||||||
|
|
||||||
|
bool hq = App.GetSetting("OcrHighQuality") == "1";
|
||||||
|
string url = (hq
|
||||||
|
? "https://raw.githubusercontent.com/tesseract-ocr/tessdata_best/main/"
|
||||||
|
: "https://raw.githubusercontent.com/tesseract-ocr/tessdata_fast/main/") + lang + ".traineddata";
|
||||||
|
con.WriteLine($"Downloading OCR language '{lang}' ({(hq ? "high quality" : "standard")})...");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
using var http = OcrLanguages.MakeDownloadClient();
|
||||||
|
using var resp = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
var part = dest + ".part";
|
||||||
|
using (var s = await resp.Content.ReadAsStreamAsync().ConfigureAwait(false))
|
||||||
|
using (var f = File.Create(part))
|
||||||
|
await s.CopyToAsync(f).ConfigureAwait(false);
|
||||||
|
if (File.Exists(dest)) File.Delete(dest);
|
||||||
|
File.Move(part, dest);
|
||||||
|
}).GetAwaiter().GetResult();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
con.WriteLine($"Could not download language '{lang}': " + FlattenBatchDetail(ex.Message));
|
||||||
|
con.WriteLine("Check the language code (e.g. eng, spa, fra, deu, jpn, tur, ben, chi_sim, chi_tra) and your connection.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace KillerPDF.Features
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The three things every feature needs from the window: an owner for modal dialogs, string
|
||||||
|
/// lookup, and the status line. Each feature's own interface extends this, so the shell
|
||||||
|
/// implements these once rather than once per feature.
|
||||||
|
///
|
||||||
|
/// Ported from Killendar, which is the reference implementation for this pattern.
|
||||||
|
/// </summary>
|
||||||
|
internal interface IShellServices
|
||||||
|
{
|
||||||
|
/// <summary>Owner for modal dialogs.</summary>
|
||||||
|
Window Window { get; }
|
||||||
|
|
||||||
|
/// <summary>Localized string for a Str_ key.</summary>
|
||||||
|
string Loc(string key);
|
||||||
|
|
||||||
|
/// <summary>Writes the status line.</summary>
|
||||||
|
void SetStatus(string text);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace KillerPDF.Features
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// What OcrController needs from the window hosting it, beyond the shared shell services.
|
||||||
|
///
|
||||||
|
/// Every member is a value, a plain string, or an intent ("put up the busy overlay"), never a
|
||||||
|
/// control, so the controller holds no reference to a Border or a Dictionary of window state
|
||||||
|
/// and can be driven by a stub in a test.
|
||||||
|
/// </summary>
|
||||||
|
internal interface IOcrHost : IShellServices
|
||||||
|
{
|
||||||
|
/// <summary>True while a document is open (both the live doc and its backing file).</summary>
|
||||||
|
bool HasDocument { get; }
|
||||||
|
|
||||||
|
/// <summary>Page count of the open document, 0 when none.</summary>
|
||||||
|
int PageCount { get; }
|
||||||
|
|
||||||
|
/// <summary>Path of the working (temp) file the renderer reads. Null when no document.</summary>
|
||||||
|
string? CurrentFile { get; }
|
||||||
|
|
||||||
|
/// <summary>Path of the file the user actually opened, for suggesting output names.
|
||||||
|
/// Null for a document that never had an original (e.g. built from images).</summary>
|
||||||
|
string? OriginalFile { get; }
|
||||||
|
|
||||||
|
/// <summary>The page's in-memory rotation in degrees, 0 when untouched. The working file
|
||||||
|
/// has /Rotate stripped, so renders must rotate the pixel buffer by this.</summary>
|
||||||
|
int RotationFor(int pageIdx);
|
||||||
|
|
||||||
|
/// <summary>Render-dim space of the page overlay (the (w,h) its canvas coordinates live
|
||||||
|
/// in), for mapping a canvas rect onto the OCR bitmap. False when the page has not
|
||||||
|
/// rendered yet.</summary>
|
||||||
|
bool TryGetRenderDims(int pageIdx, out int w, out int h);
|
||||||
|
|
||||||
|
/// <summary>The '+'-joined Tesseract language string, e.g. "eng" or "eng+spa".</summary>
|
||||||
|
string OcrLanguageString { get; }
|
||||||
|
|
||||||
|
/// <summary>Makes sure the selected language models are on disk, downloading behind a
|
||||||
|
/// heads-up dialog if not. False when the user declined or the download failed.</summary>
|
||||||
|
Task<bool> EnsureOcrModelsReadyAsync();
|
||||||
|
|
||||||
|
/// <summary>Commits any annotation text box still being edited, so the saved snapshot
|
||||||
|
/// matches what is on screen.</summary>
|
||||||
|
void CommitActiveTextBox();
|
||||||
|
|
||||||
|
/// <summary>Saves the live document to <paramref name="path"/>. Throws on failure.</summary>
|
||||||
|
void SaveDocumentTo(string path);
|
||||||
|
|
||||||
|
/// <summary>Registers the cancellable operation (Esc offers to cancel it) and puts up the
|
||||||
|
/// busy overlay. Returns the token to thread through the work.</summary>
|
||||||
|
CancellationToken BeginOp(string label, string busyMessage);
|
||||||
|
|
||||||
|
/// <summary>Updates the busy overlay's message line (per-page progress). UI thread only.</summary>
|
||||||
|
void SetBusyMessage(string message);
|
||||||
|
|
||||||
|
/// <summary>Takes the busy overlay down - before any completion dialog, so the overlay is
|
||||||
|
/// gone when the dialog appears. Safe to call when it is already down.</summary>
|
||||||
|
void HideBusy();
|
||||||
|
|
||||||
|
/// <summary>Disposes the cancellable-operation registration. Always called from finally.</summary>
|
||||||
|
void EndOp();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows;
|
||||||
|
using Docnet.Core;
|
||||||
|
using Docnet.Core.Models;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using PdfSharpCore.Drawing;
|
||||||
|
using PdfSharpCore.Pdf;
|
||||||
|
using PdfSharpCore.Pdf.IO;
|
||||||
|
using KillerPDF.Services;
|
||||||
|
|
||||||
|
namespace KillerPDF.Features
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The four OCR operations that work on the open document: page to clipboard, region to
|
||||||
|
/// clipboard, searchable PDF, and extract-all-text. Moved out of Ocr.cs (MainWindow) in the
|
||||||
|
/// KillerUI refactor.
|
||||||
|
///
|
||||||
|
/// Holds no controls. Talks to the window only through <see cref="IOcrHost"/>, so the whole
|
||||||
|
/// of this file is testable against a stub host. The pure language/download helpers live in
|
||||||
|
/// Services/OcrLanguages.cs; the OCR menu, region arming, and model downloads stay in the
|
||||||
|
/// shell half (Shell/Ocr.cs).
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class OcrController
|
||||||
|
{
|
||||||
|
// Longest-side pixel budget for the OCR render. ~300 DPI on a Letter page, which is the sweet
|
||||||
|
// spot for Tesseract: high enough for small body text, not so high it wastes time/memory.
|
||||||
|
private const int OcrRenderMax = 2600;
|
||||||
|
|
||||||
|
private readonly IOcrHost _host;
|
||||||
|
|
||||||
|
internal OcrController(IOcrHost host) => _host = host;
|
||||||
|
|
||||||
|
// Right-click "OCR Page" action: rasterize the page, recognize text off the UI thread, and drop
|
||||||
|
// the result on the clipboard. Render + OCR are both slow, so they run inside Task.Run behind the
|
||||||
|
// busy overlay; everything touching the clipboard/UI happens back on the UI thread.
|
||||||
|
internal async void OcrPageToClipboard(int pageIdx)
|
||||||
|
{
|
||||||
|
if (!_host.HasDocument) { KillerDialog.Show(_host.Window, _host.Loc("Str_Msg_OpenFirst")); return; }
|
||||||
|
if (pageIdx < 0 || pageIdx >= _host.PageCount) return;
|
||||||
|
if (!await _host.EnsureOcrModelsReadyAsync()) return;
|
||||||
|
|
||||||
|
// Capture everything off the live UI state before going async.
|
||||||
|
string file = _host.CurrentFile!;
|
||||||
|
int rot = _host.RotationFor(pageIdx);
|
||||||
|
string lang = _host.OcrLanguageString;
|
||||||
|
|
||||||
|
var ct = _host.BeginOp(_host.Loc("Str_Op_Ocr"), _host.Loc("Str_Busy_Ocr"));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
OcrResult result = await Task.Run(() =>
|
||||||
|
{
|
||||||
|
using var docReader = DocLib.Instance.GetDocReader(file, new PageDimensions(OcrRenderMax, OcrRenderMax));
|
||||||
|
using var pageReader = docReader.GetPageReader(pageIdx);
|
||||||
|
|
||||||
|
int w = pageReader.GetPageWidth();
|
||||||
|
int h = pageReader.GetPageHeight();
|
||||||
|
byte[] bgra = pageReader.GetImage();
|
||||||
|
|
||||||
|
// Temp file has /Rotate stripped, so rotate the pixel buffer to the page's visual orientation.
|
||||||
|
if (rot != 0) (bgra, w, h) = BitmapHelpers.RotateBitmap(bgra, w, h, rot);
|
||||||
|
|
||||||
|
using var ocr = new OcrService(language: lang); // engine is not thread-safe: one per operation
|
||||||
|
return ocr.RecognizeBgra(bgra, w, h);
|
||||||
|
});
|
||||||
|
|
||||||
|
_host.HideBusy();
|
||||||
|
// Cooperative cancel: a single page can't be interrupted mid-recognition, so we just discard
|
||||||
|
// the result if the user canceled. No exceptions are thrown for cancellation anywhere.
|
||||||
|
if (ct.IsCancellationRequested) { _host.SetStatus(_host.Loc("Str_St_OcrCanceled")); return; }
|
||||||
|
|
||||||
|
string text = result.Text.Trim();
|
||||||
|
if (text.Length == 0)
|
||||||
|
{
|
||||||
|
_host.SetStatus(string.Format(_host.Loc("Str_St_OcrNoTextPage"), pageIdx + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Clipboard.SetText(text);
|
||||||
|
_host.SetStatus(string.Format(_host.Loc("Str_St_OcrCopiedPage"), text.Length, pageIdx + 1, result.MeanConfidence.ToString("P0")));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_host.HideBusy();
|
||||||
|
KillerDialog.Show(_host.Window, _host.Loc("Str_Err_OcrFailed") + "\n" + ex.Message, "KillerPDF",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_host.EndOp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OCR Region: armed by the shell's menu item (Select-tool box-drag); Annotations' drag handler
|
||||||
|
// lands here with the page index and the canvas-space rect. Works on scans with no text layer.
|
||||||
|
internal async void OcrRegion(int pageIdx, Rect canvasBounds)
|
||||||
|
{
|
||||||
|
if (!_host.HasDocument) return;
|
||||||
|
if (pageIdx < 0 || pageIdx >= _host.PageCount) return;
|
||||||
|
if (!_host.TryGetRenderDims(pageIdx, out int renderW, out int renderH) || renderW <= 0 || renderH <= 0) return;
|
||||||
|
if (canvasBounds.Width < 4 || canvasBounds.Height < 4) { _host.SetStatus(_host.Loc("Str_St_OcrRegionTooSmall")); return; }
|
||||||
|
if (!await _host.EnsureOcrModelsReadyAsync()) return;
|
||||||
|
|
||||||
|
string file = _host.CurrentFile!;
|
||||||
|
int rot = _host.RotationFor(pageIdx);
|
||||||
|
string lang = _host.OcrLanguageString;
|
||||||
|
Rect cb = canvasBounds;
|
||||||
|
|
||||||
|
var ct = _host.BeginOp(_host.Loc("Str_Op_OcrRegion"), _host.Loc("Str_Busy_Region"));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
OcrResult result = await Task.Run(() =>
|
||||||
|
{
|
||||||
|
using var docReader = DocLib.Instance.GetDocReader(file, new PageDimensions(OcrRenderMax, OcrRenderMax));
|
||||||
|
using var pageReader = docReader.GetPageReader(pageIdx);
|
||||||
|
int w = pageReader.GetPageWidth();
|
||||||
|
int h = pageReader.GetPageHeight();
|
||||||
|
byte[] bgra = pageReader.GetImage();
|
||||||
|
if (rot != 0) (bgra, w, h) = BitmapHelpers.RotateBitmap(bgra, w, h, rot);
|
||||||
|
|
||||||
|
double sx = (double)w / renderW, sy = (double)h / renderH;
|
||||||
|
byte[] crop = CropBgra(bgra, w, h,
|
||||||
|
(int)Math.Round(cb.Left * sx), (int)Math.Round(cb.Top * sy),
|
||||||
|
(int)Math.Round(cb.Width * sx), (int)Math.Round(cb.Height * sy),
|
||||||
|
out int cw, out int chh);
|
||||||
|
|
||||||
|
using var ocr = new OcrService(language: lang);
|
||||||
|
return ocr.RecognizeBgra(crop, cw, chh);
|
||||||
|
});
|
||||||
|
|
||||||
|
_host.HideBusy();
|
||||||
|
if (ct.IsCancellationRequested) { _host.SetStatus(_host.Loc("Str_St_OcrCanceled")); return; }
|
||||||
|
|
||||||
|
string text = result.Text.Trim();
|
||||||
|
if (text.Length == 0) { _host.SetStatus(_host.Loc("Str_St_OcrNoText")); return; }
|
||||||
|
Clipboard.SetText(text);
|
||||||
|
_host.SetStatus(string.Format(_host.Loc("Str_St_OcrCopiedRegion"), text.Length, result.MeanConfidence.ToString("P0")));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_host.HideBusy();
|
||||||
|
KillerDialog.Show(_host.Window, _host.Loc("Str_Err_OcrFailed") + "\n" + ex.Message, "KillerPDF", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_host.EndOp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] CropBgra(byte[] src, int srcW, int srcH, int x, int y, int cw, int ch, out int outW, out int outH)
|
||||||
|
{
|
||||||
|
x = Math.Max(0, Math.Min(x, srcW - 1));
|
||||||
|
y = Math.Max(0, Math.Min(y, srcH - 1));
|
||||||
|
outW = Math.Max(1, Math.Min(cw, srcW - x));
|
||||||
|
outH = Math.Max(1, Math.Min(ch, srcH - y));
|
||||||
|
var dst = new byte[outW * outH * 4];
|
||||||
|
for (int row = 0; row < outH; row++)
|
||||||
|
Array.Copy(src, ((y + row) * srcW + x) * 4, dst, row * outW * 4, outW * 4);
|
||||||
|
return dst;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Make Searchable PDF - OCR every page and write an invisible text
|
||||||
|
// layer aligned to the image, so the existing PdfPig search and text
|
||||||
|
// selection start working on scans.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
internal async void MakeSearchablePdf()
|
||||||
|
{
|
||||||
|
if (!_host.HasDocument) { KillerDialog.Show(_host.Window, _host.Loc("Str_Ocr_NoDoc")); return; }
|
||||||
|
if (!await _host.EnsureOcrModelsReadyAsync()) return;
|
||||||
|
_host.CommitActiveTextBox();
|
||||||
|
|
||||||
|
var dlg = new KillerPDF.Controls.FileDialog(KillerPDF.Controls.FileDialogMode.Save)
|
||||||
|
{
|
||||||
|
Filter = _host.Loc("Str_Filter_Pdf") + "|*.pdf",
|
||||||
|
Title = _host.Loc("Str_Ocr_SaveSearchable"),
|
||||||
|
FileName = SuggestSearchableName(),
|
||||||
|
CheckFileExists = false,
|
||||||
|
CheckPathExists = true
|
||||||
|
};
|
||||||
|
if (dlg.ShowDialog(_host.Window) != true) return;
|
||||||
|
string outPath = dlg.FileName;
|
||||||
|
|
||||||
|
// Snapshot the current document to a temp; we render and re-open from this so the live doc
|
||||||
|
// is never touched. (Unburned overlay annotations are not included in v1.)
|
||||||
|
string src = App.MakeTempFile("ocrsrc");
|
||||||
|
try { _host.SaveDocumentTo(src); }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
KillerDialog.Show(_host.Window, _host.Loc("Str_Err_PrepareDoc") + "\n" + ex.Message, "KillerPDF",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ct = _host.BeginOp(_host.Loc("Str_Op_Ocr"), _host.Loc("Str_Busy_Searchable"));
|
||||||
|
void report(int i, int n) => _host.Window.Dispatcher.Invoke(() =>
|
||||||
|
_host.SetBusyMessage(string.Format(_host.Loc("Str_Busy_SearchablePage"), i + 1, n)));
|
||||||
|
string lang = _host.OcrLanguageString;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (pages, words) = await Task.Run(() => BuildSearchablePdf(src, outPath, report, ct, lang));
|
||||||
|
_host.HideBusy();
|
||||||
|
if (ct.IsCancellationRequested) { _host.SetStatus(_host.Loc("Str_St_SearchablePdfCanceled")); return; }
|
||||||
|
_host.SetStatus(string.Format(_host.Loc("Str_St_SearchableSaved"), pages, words));
|
||||||
|
KillerDialog.Show(_host.Window,
|
||||||
|
string.Format(_host.Loc("Str_Dlg_SearchableSaved"), outPath, pages, words),
|
||||||
|
"KillerPDF", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_host.HideBusy();
|
||||||
|
KillerDialog.Show(_host.Window, _host.Loc("Str_Err_SearchableFailed") + "\n" + ex.Message, "KillerPDF",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_host.EndOp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggest "<original name>-searchable.pdf" for the save dialog.
|
||||||
|
private string SuggestSearchableName()
|
||||||
|
{
|
||||||
|
string baseName = Path.GetFileNameWithoutExtension(_host.OriginalFile ?? _host.CurrentFile ?? "document");
|
||||||
|
return baseName + "-searchable.pdf";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders each page, OCRs it, and appends an invisible (alpha 0) text layer positioned over the
|
||||||
|
// recognized words. The text is real content-stream text, so PdfPig extracts it for search/select;
|
||||||
|
// alpha 0 keeps it from showing or printing. Runs entirely off the UI thread. Also the core of the
|
||||||
|
// CLI's --ocr command (CliRunner).
|
||||||
|
internal static (int pages, int words) BuildSearchablePdf(string src, string outPath, Action<int, int> report, CancellationToken ct, string language)
|
||||||
|
{
|
||||||
|
// Cache one XFont per integer point size so a page of words doesn't allocate thousands of fonts.
|
||||||
|
var fontCache = new Dictionary<int, XFont>();
|
||||||
|
XFont FontFor(double heightPt)
|
||||||
|
{
|
||||||
|
int key = Math.Max(4, (int)Math.Round(heightPt));
|
||||||
|
if (!fontCache.TryGetValue(key, out var f))
|
||||||
|
{
|
||||||
|
try { f = new XFont("Arial", key, XFontStyle.Regular); }
|
||||||
|
catch { f = new XFont("Segoe UI", key, XFontStyle.Regular); }
|
||||||
|
fontCache[key] = f;
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
int totalWords = 0;
|
||||||
|
var invisible = new XSolidBrush(XColor.FromArgb(0, 0, 0, 0));
|
||||||
|
|
||||||
|
using var docReader = DocLib.Instance.GetDocReader(src, new PageDimensions(OcrRenderMax, OcrRenderMax));
|
||||||
|
using var ocr = new OcrService(language: language); // one engine reused across the whole document (single-threaded here)
|
||||||
|
|
||||||
|
var outDoc = PdfReader.Open(src, PdfDocumentOpenMode.Modify);
|
||||||
|
int pages = outDoc.PageCount;
|
||||||
|
for (int i = 0; i < pages; i++)
|
||||||
|
{
|
||||||
|
// Cooperative cancel: bail before the next page; the caller sees the canceled token and the
|
||||||
|
// file is never saved (outDoc.Save is past the loop), so no partial output is written.
|
||||||
|
if (ct.IsCancellationRequested) return (i, totalWords);
|
||||||
|
report(i, pages);
|
||||||
|
|
||||||
|
using var pr = docReader.GetPageReader(i);
|
||||||
|
int w = pr.GetPageWidth();
|
||||||
|
int h = pr.GetPageHeight();
|
||||||
|
byte[] bgra = pr.GetImage();
|
||||||
|
if (bgra is null || bgra.Length == 0 || w <= 0 || h <= 0) continue;
|
||||||
|
|
||||||
|
OcrResult result = ocr.RecognizeBgra(bgra, w, h);
|
||||||
|
if (result.Words.Count == 0) continue;
|
||||||
|
|
||||||
|
var page = outDoc.Pages[i];
|
||||||
|
using var gfx = XGraphics.FromPdfPage(page, XGraphicsPdfPageOptions.Append);
|
||||||
|
|
||||||
|
// OCR boxes are top-left pixel space; XGraphics is top-left point space. Same convention,
|
||||||
|
// so mapping is a straight scale (mirrors DrawAnnotationsOnDocument).
|
||||||
|
double sx = page.Width.Point / w;
|
||||||
|
double sy = page.Height.Point / h;
|
||||||
|
|
||||||
|
foreach (var word in result.Words)
|
||||||
|
{
|
||||||
|
double bx = word.Left * sx;
|
||||||
|
double by = word.Top * sy;
|
||||||
|
double bh = Math.Max(1, (word.Bottom - word.Top) * sy);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// (bx, by) is the top-left of the text by default (Near/Near alignment).
|
||||||
|
gfx.DrawString(word.Text, FontFor(bh), invisible, bx, by);
|
||||||
|
totalWords++;
|
||||||
|
}
|
||||||
|
catch { /* a single word that won't lay out should not abort the page */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outDoc.Save(outPath);
|
||||||
|
outDoc.Close();
|
||||||
|
return (pages, totalWords);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Extract All Text - OCR every page and save the plain text to a .txt or .md file.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
internal async void ExtractAllText()
|
||||||
|
{
|
||||||
|
if (!_host.HasDocument) { KillerDialog.Show(_host.Window, _host.Loc("Str_Ocr_NoDoc")); return; }
|
||||||
|
if (!await _host.EnsureOcrModelsReadyAsync()) return;
|
||||||
|
_host.CommitActiveTextBox();
|
||||||
|
|
||||||
|
var dlg = new KillerPDF.Controls.FileDialog(KillerPDF.Controls.FileDialogMode.Save)
|
||||||
|
{
|
||||||
|
Filter = _host.Loc("Str_Filter_Text") + "|*.txt|Markdown|*.md",
|
||||||
|
Title = _host.Loc("Str_Ocr_ExtractAllText"),
|
||||||
|
FileName = Path.GetFileNameWithoutExtension(_host.OriginalFile ?? _host.CurrentFile ?? "document") + ".txt",
|
||||||
|
CheckFileExists = false,
|
||||||
|
CheckPathExists = true
|
||||||
|
};
|
||||||
|
if (dlg.ShowDialog(_host.Window) != true) return;
|
||||||
|
string outPath = dlg.FileName;
|
||||||
|
bool markdown = Path.GetExtension(outPath).Equals(".md", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
string src = App.MakeTempFile("ocrtxt");
|
||||||
|
int pageCount;
|
||||||
|
try { _host.SaveDocumentTo(src); pageCount = _host.PageCount; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
KillerDialog.Show(_host.Window, _host.Loc("Str_Err_PrepareDoc") + "\n" + ex.Message, "KillerPDF",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ct = _host.BeginOp(_host.Loc("Str_Op_Ocr"), _host.Loc("Str_Busy_Extracting"));
|
||||||
|
void report(int i, int n) => _host.Window.Dispatcher.Invoke(() =>
|
||||||
|
_host.SetBusyMessage(string.Format(_host.Loc("Str_Busy_ExtractingPage"), i + 1, n)));
|
||||||
|
string lang = _host.OcrLanguageString;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int pages = await Task.Run(() => ExtractText(src, pageCount, outPath, markdown, report, ct, lang));
|
||||||
|
_host.HideBusy();
|
||||||
|
if (ct.IsCancellationRequested) { _host.SetStatus(_host.Loc("Str_St_TextExtractCanceled")); return; }
|
||||||
|
_host.SetStatus(string.Format(_host.Loc("Str_St_TextExtracted"), pages, Path.GetFileName(outPath)));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_host.HideBusy();
|
||||||
|
KillerDialog.Show(_host.Window, _host.Loc("Str_Err_ExtractFailed") + "\n" + ex.Message, "KillerPDF",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_host.EndOp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OCR each page and concatenate the text into one file. Markdown gets a "## Page N" heading per
|
||||||
|
// page; plain text uses a simple divider. Cancellable - nothing is written if canceled.
|
||||||
|
private static int ExtractText(string src, int pageCount, string outPath, bool markdown,
|
||||||
|
Action<int, int> report, CancellationToken ct, string language)
|
||||||
|
{
|
||||||
|
string nl = Environment.NewLine;
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
using var docReader = DocLib.Instance.GetDocReader(src, new PageDimensions(OcrRenderMax, OcrRenderMax));
|
||||||
|
using var ocr = new OcrService(language: language);
|
||||||
|
|
||||||
|
for (int i = 0; i < pageCount; i++)
|
||||||
|
{
|
||||||
|
// Cooperative cancel: stop and write nothing if the user canceled (caller checks the token).
|
||||||
|
if (ct.IsCancellationRequested) return 0;
|
||||||
|
report(i, pageCount);
|
||||||
|
|
||||||
|
using var pr = docReader.GetPageReader(i);
|
||||||
|
int w = pr.GetPageWidth();
|
||||||
|
int h = pr.GetPageHeight();
|
||||||
|
byte[] bgra = pr.GetImage();
|
||||||
|
string text = (bgra is null || bgra.Length == 0 || w <= 0 || h <= 0)
|
||||||
|
? string.Empty
|
||||||
|
: ocr.RecognizeBgra(bgra, w, h).Text.TrimEnd();
|
||||||
|
// Normalize Tesseract's LF line breaks to the platform's so .txt opens cleanly everywhere.
|
||||||
|
text = text.Replace("\r\n", "\n").Replace("\n", nl);
|
||||||
|
|
||||||
|
if (markdown)
|
||||||
|
sb.Append("## Page ").Append(i + 1).Append(nl).Append(nl).Append(text).Append(nl).Append(nl);
|
||||||
|
else
|
||||||
|
sb.Append("----- Page ").Append(i + 1).Append(" -----").Append(nl).Append(text).Append(nl).Append(nl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ct.IsCancellationRequested) return 0;
|
||||||
|
File.WriteAllText(outPath, sb.ToString());
|
||||||
|
return pageCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
namespace KillerPDF.Features
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// What SearchController needs from the window hosting it, beyond the shared shell services.
|
||||||
|
/// Values and intents only, never a control - the highlight painting itself stays in the
|
||||||
|
/// shell (Shell/Search.cs), because it draws onto the page overlay canvases.
|
||||||
|
/// </summary>
|
||||||
|
internal interface ISearchHost : IShellServices
|
||||||
|
{
|
||||||
|
/// <summary>Path of the working (temp) file the search reads. Null when no document.</summary>
|
||||||
|
string? CurrentFile { get; }
|
||||||
|
|
||||||
|
/// <summary>The page currently selected in the sidebar/page list.</summary>
|
||||||
|
int CurrentPageIndex { get; }
|
||||||
|
|
||||||
|
/// <summary>Navigates to the page (the match being stepped to lives there).</summary>
|
||||||
|
void GoToPage(int pageIdx);
|
||||||
|
|
||||||
|
/// <summary>Writes the result counter's text only ("", "No matches", "Search error"),
|
||||||
|
/// leaving the tooltip alone - mirrors what the old code did on those paths.</summary>
|
||||||
|
void SetResultText(string text);
|
||||||
|
|
||||||
|
/// <summary>Writes the "12 / 73" counter and its page-breakdown tooltip.</summary>
|
||||||
|
void SetResultCount(string text, string? tooltip);
|
||||||
|
|
||||||
|
/// <summary>Removes every highlight rectangle from the page overlays.</summary>
|
||||||
|
void ClearHighlights();
|
||||||
|
|
||||||
|
/// <summary>Repaints highlights on every page on screen right now, with the current
|
||||||
|
/// match emphasized.</summary>
|
||||||
|
void RepaintHighlights();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
using KillerPDF.Services;
|
||||||
|
|
||||||
|
namespace KillerPDF.Features
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The document-search state machine: runs SearchService over the working file, keeps the
|
||||||
|
/// per-page rect map and the flat reading-ordered match list, and steps the match cursor.
|
||||||
|
/// Moved out of Search.cs (MainWindow) in the KillerUI refactor.
|
||||||
|
///
|
||||||
|
/// Holds no controls. Talks to the window only through <see cref="ISearchHost"/>; the search
|
||||||
|
/// bar UI, debounce, and the highlight painting stay in the shell half (Shell/Search.cs).
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SearchController
|
||||||
|
{
|
||||||
|
private readonly ISearchHost _host;
|
||||||
|
private readonly SearchService _searchService = new();
|
||||||
|
|
||||||
|
internal SearchController(ISearchHost host) => _host = host;
|
||||||
|
|
||||||
|
// Whole-document search results (PDF-space rects per page). Settable references on
|
||||||
|
// purpose: Tabs.cs parks these per tab and swaps them back on a tab switch, exactly as
|
||||||
|
// it did when they were MainWindow fields.
|
||||||
|
internal Dictionary<int, List<(double left, double bottom, double right, double top)>> AllSearchRects { get; set; } = [];
|
||||||
|
internal List<int> ResultPages { get; set; } = [];
|
||||||
|
internal int PageCursor { get; set; } = -1;
|
||||||
|
|
||||||
|
// Flat, reading-ordered list of every match (page + rect) so Enter steps word-by-word rather
|
||||||
|
// than page-by-page; _matchCursor indexes it and that match is drawn with extra emphasis.
|
||||||
|
private readonly List<(int page, double left, double bottom, double right, double top)> _matches = [];
|
||||||
|
private int _matchCursor = -1;
|
||||||
|
private int _totalHits;
|
||||||
|
|
||||||
|
/// <summary>True while any page has result rects - the F3 and repaint-on-page-change gate.</summary>
|
||||||
|
internal bool HasResults => AllSearchRects.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>The emphasized match, or null when the cursor is not on one.</summary>
|
||||||
|
internal (int page, double left, double bottom, double right, double top)? CurrentMatch =>
|
||||||
|
_matchCursor >= 0 && _matchCursor < _matches.Count ? _matches[_matchCursor] : null;
|
||||||
|
|
||||||
|
internal bool TryGetPageRects(int page,
|
||||||
|
out List<(double left, double bottom, double right, double top)> rects) =>
|
||||||
|
AllSearchRects.TryGetValue(page, out rects!);
|
||||||
|
|
||||||
|
internal IEnumerable<int> PagesWithResults => AllSearchRects.Keys;
|
||||||
|
|
||||||
|
/// <summary>The query-too-short reset (search box text dropped under 2 chars).</summary>
|
||||||
|
internal void ClearMatches()
|
||||||
|
{
|
||||||
|
AllSearchRects.Clear();
|
||||||
|
ResultPages.Clear();
|
||||||
|
_matches.Clear();
|
||||||
|
_matchCursor = -1;
|
||||||
|
PageCursor = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The new/closed-document reset - exactly the three things FileOperations
|
||||||
|
/// cleared when these were fields (the match list is rebuilt by the next Run).</summary>
|
||||||
|
internal void ClearPageResults()
|
||||||
|
{
|
||||||
|
AllSearchRects.Clear();
|
||||||
|
ResultPages.Clear();
|
||||||
|
PageCursor = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Run(string query)
|
||||||
|
{
|
||||||
|
_host.ClearHighlights();
|
||||||
|
AllSearchRects.Clear();
|
||||||
|
ResultPages.Clear();
|
||||||
|
_matches.Clear();
|
||||||
|
_matchCursor = -1;
|
||||||
|
PageCursor = -1;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(query) || _host.CurrentFile is null)
|
||||||
|
{
|
||||||
|
_host.SetResultText("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sr = _searchService.Search(_host.CurrentFile, query);
|
||||||
|
|
||||||
|
foreach (var kvp in sr.PageRects)
|
||||||
|
AllSearchRects[kvp.Key] = kvp.Value;
|
||||||
|
ResultPages.AddRange(sr.ResultPages);
|
||||||
|
|
||||||
|
// Flatten every match into one reading-ordered list (page asc, then top-to-bottom,
|
||||||
|
// then left-to-right) so navigation steps word-by-word across the whole document.
|
||||||
|
foreach (var page in ResultPages)
|
||||||
|
foreach (var (left, bottom, right, top) in AllSearchRects[page].OrderByDescending(r => r.top).ThenBy(r => r.left))
|
||||||
|
_matches.Add((page, left, bottom, right, top));
|
||||||
|
|
||||||
|
if (_matches.Count == 0)
|
||||||
|
{
|
||||||
|
_host.SetResultText(_host.Loc("Str_Search_NoMatches"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_totalHits = sr.TotalHits;
|
||||||
|
|
||||||
|
// Start at the first match on or after the current page.
|
||||||
|
int startPage = _host.CurrentPageIndex;
|
||||||
|
_matchCursor = _matches.FindIndex(m => m.page >= startPage);
|
||||||
|
if (_matchCursor < 0) _matchCursor = 0;
|
||||||
|
|
||||||
|
GoToCurrentMatch();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_host.SetResultText(_host.Loc("Str_Search_Error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Next()
|
||||||
|
{
|
||||||
|
if (_matches.Count == 0) return;
|
||||||
|
_matchCursor = (_matchCursor + 1) % _matches.Count;
|
||||||
|
GoToCurrentMatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Prev()
|
||||||
|
{
|
||||||
|
if (_matches.Count == 0) return;
|
||||||
|
_matchCursor = (_matchCursor - 1 + _matches.Count) % _matches.Count;
|
||||||
|
GoToCurrentMatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigates to the current match's page (if needed), updates the counter, and repaints
|
||||||
|
// highlights with the current match emphasized. Shared by Run and Next/Prev.
|
||||||
|
private void GoToCurrentMatch()
|
||||||
|
{
|
||||||
|
if (_matchCursor < 0 || _matchCursor >= _matches.Count) return;
|
||||||
|
int targetPage = _matches[_matchCursor].page;
|
||||||
|
PageCursor = ResultPages.IndexOf(targetPage); // keep the persisted page-cursor sane
|
||||||
|
UpdateStatus();
|
||||||
|
if (_host.CurrentPageIndex != targetPage)
|
||||||
|
_host.GoToPage(targetPage);
|
||||||
|
_host.RepaintHighlights();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact count ("12 / 73" = current match / total matches); page breakdown in the tooltip.
|
||||||
|
private void UpdateStatus()
|
||||||
|
{
|
||||||
|
if (_matches.Count == 0)
|
||||||
|
{
|
||||||
|
_host.SetResultCount(_host.Loc("Str_Search_NoMatches"), null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int pages = ResultPages.Count;
|
||||||
|
_host.SetResultCount($"{_matchCursor + 1} / {_matches.Count}",
|
||||||
|
string.Format(_host.Loc("Str_Search_Summary"), _matches.Count, pages));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using KillerPDF.Controls;
|
||||||
|
|
||||||
|
namespace KillerPDF.Features
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// What a document viewer needs from the window around it.
|
||||||
|
///
|
||||||
|
/// EXTENDS IShellServices, per the family rule in that file - the shell implements Window /
|
||||||
|
/// Loc / SetStatus once, not once per feature. Those three cover the viewer's two heaviest
|
||||||
|
/// call groups on their own (Loc 70 uses, SetStatus 68) plus the modal-dialog owner that
|
||||||
|
/// TextEditing and Links need for KillerDialog (3 uses).
|
||||||
|
///
|
||||||
|
/// DERIVED FROM MEASUREMENT, not guessed: every member here came from grepping what the nine
|
||||||
|
/// files bound for the viewer control (Viewport, Zoom, Annotations, Selection, TextEditing,
|
||||||
|
/// Crop, Links, Forms, PageSelection) actually reach for on MainWindow today. Use counts are
|
||||||
|
/// noted per member so the cost of each is visible.
|
||||||
|
///
|
||||||
|
/// That audit split the coupling into three groups, and only the first belongs here:
|
||||||
|
///
|
||||||
|
/// A. HOST SERVICES - chrome and app-level services the viewer asks for. This interface.
|
||||||
|
///
|
||||||
|
/// B. PER-DOCUMENT STATE - _doc (120 uses), _currentFile (33), _annotations (56),
|
||||||
|
/// _renderDims (34), _pageRotations (11), _undoStack (4). These are NOT host services:
|
||||||
|
/// they already ride in DocumentSession, which tab switching swaps by reference. The
|
||||||
|
/// viewer will hold its own active session and read them from it. Routing them through
|
||||||
|
/// the host would be a mistake that undoes the session design.
|
||||||
|
///
|
||||||
|
/// C. PageList - 84 uses, the single biggest coupling, and a DESIGN DECISION rather than a
|
||||||
|
/// mechanical one. The sidebar's page-thumbnail list is window chrome, but the viewer
|
||||||
|
/// drives it constantly (selection sync, scroll-to-page). With two panes there is still
|
||||||
|
/// ONE sidebar, so it has to follow the FOCUSED pane. The viewer therefore must not touch
|
||||||
|
/// PageList directly; it raises the notifications below and the window decides whether
|
||||||
|
/// this viewer is the focused one before acting. Getting this wrong is how the two panes
|
||||||
|
/// would end up fighting over the sidebar.
|
||||||
|
/// </summary>
|
||||||
|
internal interface IViewerHost : IShellServices
|
||||||
|
{
|
||||||
|
// ── Chrome the viewer updates (group A) ─────────────────────────────────────────────
|
||||||
|
/// <summary>Mark the active document dirty (unsaved changes). 32 uses. Stays a host
|
||||||
|
/// service even though dirtiness is per-document, because it also drives window chrome -
|
||||||
|
/// the tab's dirty dot and the title bar.</summary>
|
||||||
|
void MarkDirty(bool dirty = true);
|
||||||
|
|
||||||
|
// PushUndo is deliberately NOT here, despite 9 uses. Undo is per-document state (group B):
|
||||||
|
// _undoStack rides in DocumentSession, so the viewer pushes onto the session it is showing
|
||||||
|
// rather than asking the window. It was in this interface briefly and the compiler caught
|
||||||
|
// it - UndoEntry is a private nested record struct on MainWindow, and widening it plus its
|
||||||
|
// UndoKind enum just to satisfy the signature would have been the wrong fix for a member
|
||||||
|
// that should not have been here. (2026-08-01.)
|
||||||
|
|
||||||
|
/// <summary>Switch tools - Crop uses this to drop back to Select when it finishes. 2 uses.</summary>
|
||||||
|
void SetTool(EditTool tool);
|
||||||
|
bool SidebarShowingOutlines { get; }
|
||||||
|
void PopulateRecentFilesList(PdfViewer viewer);
|
||||||
|
void SwitchSidebarToPagesTab();
|
||||||
|
void SyncSidebarToDocState(bool hasDoc, bool startup);
|
||||||
|
void OpenFile(string path);
|
||||||
|
void UpdateFooterFade();
|
||||||
|
void UpdateTabStripFade();
|
||||||
|
Border? SearchBar { get; }
|
||||||
|
SearchController Search { get; }
|
||||||
|
TextBlock FileNameLabel { get; }
|
||||||
|
TreeView OutlineTree { get; }
|
||||||
|
Button SidebarOutlinesTab { get; }
|
||||||
|
TextBlock StatusText { get; }
|
||||||
|
FrameworkElement ShortcutOverlay { get; }
|
||||||
|
CheckBox LinkConfirmCheck { get; }
|
||||||
|
ContextMenu MakeThemedMenu();
|
||||||
|
void CloseSearchBar();
|
||||||
|
void HideSignaturePopup();
|
||||||
|
void SaveTempAndReload(bool keepAnnotations, bool preserveZoom);
|
||||||
|
void RecordNavJump();
|
||||||
|
PageAnnotation? PairPartner(PageAnnotation annotation);
|
||||||
|
void RenderStamps(int page);
|
||||||
|
void OpenStampTool();
|
||||||
|
bool StampHitTest(int page, Point position);
|
||||||
|
void ApplySearchHighlights(int page, Canvas canvas);
|
||||||
|
void HighlightSearchResultsOnCurrentPage();
|
||||||
|
void ShowTextSettings();
|
||||||
|
void HideTextSettings();
|
||||||
|
void StyleEditBox(TextBox textBox);
|
||||||
|
void ApplyTextStyleToSelection();
|
||||||
|
void ShowDrawSettings(EditTool tool);
|
||||||
|
void HideDrawSettings();
|
||||||
|
Border MakeBarGrip(int dotCount);
|
||||||
|
FrameworkElement BuildBarHost(FrameworkElement content);
|
||||||
|
void PlaceAnnotationBar(Border bar, Border grip, bool fadeIn);
|
||||||
|
void PlaceImageFromDialog(Point position, int pageIndex);
|
||||||
|
void PlaceSignature(Point position, int pageIndex);
|
||||||
|
void ShowSignaturePopup();
|
||||||
|
void FillSignField(bool initials, int objectNumber, int pageIndex,
|
||||||
|
double x, double y, double width, double height);
|
||||||
|
void ShapeToolMouseDown(int pageIndex, Point position, MouseButtonEventArgs e);
|
||||||
|
void CommitShapeDrag(int pageIndex);
|
||||||
|
void UpdateShapePolyRubber(MouseEventArgs e);
|
||||||
|
void OcrRegion(int pageIndex, Rect canvasBounds);
|
||||||
|
void ShowShortcutsOverlayExclusive();
|
||||||
|
System.Windows.Media.SolidColorBrush SwatchDimBorder { get; }
|
||||||
|
PageAnnotation? CloneAnnotation(PageAnnotation annotation);
|
||||||
|
System.Windows.TextDecorationCollection? BuildDecorations(bool underline, bool strike);
|
||||||
|
System.Windows.Media.Effects.DropShadowEffect AnnotBarShadow();
|
||||||
|
void FadeOverlayOut(UIElement element);
|
||||||
|
void FadeOutAndRemoveBar(Border? bar);
|
||||||
|
PdfSharpCore.Pdf.PdfItem DerefItem(PdfSharpCore.Pdf.PdfItem item);
|
||||||
|
string WordsToText(System.Collections.Generic.IEnumerable<UglyToad.PdfPig.Content.Word> words);
|
||||||
|
MenuItem MakeMenuItem(string header, RoutedEventHandler click, string? gesture, string? glyph);
|
||||||
|
bool FullScreen { get; }
|
||||||
|
bool VerticalScrollVisible { get; set; }
|
||||||
|
bool SpaceHeld { get; }
|
||||||
|
void RepositionAnnotationBars();
|
||||||
|
void PopulateContextMenu(PdfViewer viewer, Point point, int pageIndex);
|
||||||
|
void RefreshPageList(PdfViewer viewer);
|
||||||
|
void LoadOutlines(PdfViewer viewer);
|
||||||
|
Cursor CursorForTool(EditTool tool);
|
||||||
|
|
||||||
|
// ── Notifications, so the window can update chrome for the FOCUSED viewer only ───────
|
||||||
|
// These replace the viewer poking at PageList / ZoomBox / PageLabel / StatusText itself.
|
||||||
|
/// <summary>This viewer scrolled or paged to a different page.</summary>
|
||||||
|
void ViewerPageChanged(PdfViewer viewer, int pageIndex);
|
||||||
|
void EnsureSidebarPageVisible(PdfViewer viewer, int pageIndex);
|
||||||
|
void ScrollSidebar(PdfViewer viewer, double delta);
|
||||||
|
void ClearSidebarPages(PdfViewer viewer);
|
||||||
|
string PageJumpText { get; set; }
|
||||||
|
bool PageJumpEnabled { set; }
|
||||||
|
bool CloseFileEnabled { set; }
|
||||||
|
string PageTotalText { set; }
|
||||||
|
void SelectAllPageJumpText();
|
||||||
|
void SyncZoomDisplay(string? fitTag, string displayText);
|
||||||
|
string? SelectedZoomTag { get; }
|
||||||
|
void CollapseZoomTextSelection();
|
||||||
|
|
||||||
|
/// <summary>This viewer's zoom or fit mode changed (updates the zoom box). 16 uses of
|
||||||
|
/// ZoomBox today.</summary>
|
||||||
|
void ViewerZoomChanged(double zoomLevel);
|
||||||
|
|
||||||
|
/// <summary>This viewer took focus - the window repoints the sidebar, page list and
|
||||||
|
/// status line at it, and moves the accent halo.</summary>
|
||||||
|
void ViewerFocused();
|
||||||
|
|
||||||
|
// Window-owned chrome and start-screen actions raised by one viewer instance.
|
||||||
|
void ViewerSizeChanged(PdfViewer viewer, object sender, SizeChangedEventArgs e);
|
||||||
|
void ViewerDrop(PdfViewer viewer, object sender, DragEventArgs e);
|
||||||
|
void ViewerDragOver(object sender, DragEventArgs e);
|
||||||
|
void ViewerDropZoneClick(object sender, MouseButtonEventArgs e);
|
||||||
|
void ClearRecentFiles(object sender, MouseButtonEventArgs e);
|
||||||
|
void ViewerBackgroundRightClick(object sender, MouseButtonEventArgs e);
|
||||||
|
void ViewerTabStripMouseDown(object sender, MouseButtonEventArgs e);
|
||||||
|
|
||||||
|
bool IsViewerFocused(PdfViewer viewer);
|
||||||
|
bool IsSplitView { get; }
|
||||||
|
void FocusViewer(PdfViewer viewer);
|
||||||
|
bool OtherViewerHasFile(PdfViewer viewer, string? originalFile);
|
||||||
|
|
||||||
|
PdfViewer? TabDropTarget(PdfViewer source, MouseEventArgs e);
|
||||||
|
void UpdateTabDragFeedback(PdfViewer source, PdfViewer.DocumentSession session,
|
||||||
|
MouseEventArgs e, PdfViewer? target);
|
||||||
|
void HideTabDragFeedback();
|
||||||
|
void MoveTabToPane(PdfViewer source, PdfViewer target,
|
||||||
|
PdfViewer.DocumentSession session, MouseEventArgs e);
|
||||||
|
|
||||||
|
void RunWithViewerContext(PdfViewer viewer, System.Action work);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The tab and session members under the names the window already calls them by, routed to the
|
||||||
|
/// focused pane. Keeping the old names resolvable leaves the call sites in FileOperations,
|
||||||
|
/// KeyboardShortcuts, ImportAndZip, TempReload, WindowChrome and SettingsPanel unchanged while
|
||||||
|
/// making them act on whichever pane has focus.
|
||||||
|
///
|
||||||
|
/// Ctrl+W, Ctrl+Tab, Ctrl+Q and CloseFile_Click are keyboard- or XAML-bound, so these
|
||||||
|
/// declarations are load-bearing rather than convenience.
|
||||||
|
/// </summary>
|
||||||
|
public partial class MainWindow
|
||||||
|
{
|
||||||
|
private void OpenInNewTab(string path) => ActiveViewer.OpenInNewTabExt(path);
|
||||||
|
private void CloseTab(Controls.PdfViewer.DocumentSession? s) => ActiveViewer.CloseTabExt(s);
|
||||||
|
private void CloseAllTabs() => ActiveViewer.CloseAllTabsExt();
|
||||||
|
private void CloseOtherTabs(Controls.PdfViewer.DocumentSession? s) => ActiveViewer.CloseOtherTabsExt(s);
|
||||||
|
private void CycleTab(int dir) => ActiveViewer.CycleTabExt(dir);
|
||||||
|
private void EnsureInitialSession() => ActiveViewer.EnsureInitialSessionExt();
|
||||||
|
private void MaterializeDeferred(Controls.PdfViewer.DocumentSession target)
|
||||||
|
=> ActiveViewer.MaterializeDeferredExt(target);
|
||||||
|
|
||||||
|
private Controls.PdfViewer.DocumentSession BeginTabLoad(
|
||||||
|
out Controls.PdfViewer.DocumentSession? prev, out bool createdNew)
|
||||||
|
=> ActiveViewer.BeginTabLoadExt(out prev, out createdNew);
|
||||||
|
private void AbortTabLoad(Controls.PdfViewer.DocumentSession target,
|
||||||
|
Controls.PdfViewer.DocumentSession? prev, bool createdNew)
|
||||||
|
=> ActiveViewer.AbortTabLoadExt(target, prev, createdNew);
|
||||||
|
|
||||||
|
private void CaptureSessionState(Controls.PdfViewer.DocumentSession s)
|
||||||
|
=> ActiveViewer.CaptureSessionStateExt(s);
|
||||||
|
private void ApplySessionState(Controls.PdfViewer.DocumentSession s)
|
||||||
|
=> ActiveViewer.ApplySessionStateExt(s);
|
||||||
|
private void SaveDocState(string? path, FitMode fit, double zoom, ViewMode view, int page)
|
||||||
|
=> ActiveViewer.SaveDocStateExt(path, fit, zoom, view, page);
|
||||||
|
private bool TryGetDocState(string? path, out FitMode fit, out double zoom,
|
||||||
|
out ViewMode view, out int page)
|
||||||
|
=> ActiveViewer.TryGetDocStateExt(path, out fit, out zoom, out view, out page);
|
||||||
|
|
||||||
|
private void RebuildTabStrip() => ActiveViewer.RebuildTabStripExt();
|
||||||
|
private void ScheduleTabReflow() => ActiveViewer.ScheduleTabReflowExt();
|
||||||
|
private void RenderActiveSession() => ActiveViewer.RenderActiveSessionExt();
|
||||||
|
private void ShowEmptyState() => ActiveViewer.ShowEmptyStateExt();
|
||||||
|
/// <summary>Night mode changed: flush both panes. The invert state is baked into cached
|
||||||
|
/// pixels, so pane B's cache is as stale as pane A's.</summary>
|
||||||
|
private void FlushAllRenderCaches()
|
||||||
|
{
|
||||||
|
Viewer.FlushAllRenderCachesExt();
|
||||||
|
ViewerB.FlushAllRenderCachesExt();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Every open document across both panes. The quit prompt and the settings writer
|
||||||
|
/// need all of them, or closing the window silently drops pane B's unsaved work.</summary>
|
||||||
|
private IEnumerable<Controls.PdfViewer.DocumentSession> AllSessions()
|
||||||
|
{
|
||||||
|
foreach (var s in Viewer.SessionsRef) yield return s;
|
||||||
|
foreach (var s in ViewerB.SessionsRef) yield return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The focused pane's open documents. Callers that mean "this pane" - the tab
|
||||||
|
/// context menu's Close Others, the reorder resync - want this rather than AllSessions.</summary>
|
||||||
|
private System.Collections.ObjectModel.ObservableCollection<Controls.PdfViewer.DocumentSession> _sessions
|
||||||
|
=> ActiveViewer.SessionsRef;
|
||||||
|
|
||||||
|
/// <summary>The focused pane's tab strip, for the chrome that positions or hides it.
|
||||||
|
/// AppScale and FullScreen act on both panes at their own call sites; SidebarLayout's fade
|
||||||
|
/// mask only describes the pane it is measuring, so it takes the active one.</summary>
|
||||||
|
private System.Windows.Controls.Border TabStripBorder => ActiveViewer.TabStripBorderCtl;
|
||||||
|
private System.Windows.Controls.Border TabStripFade => ActiveViewer.TabStripFadeCtl;
|
||||||
|
|
||||||
|
/// <summary>True when the other pane holds an unsaved copy of the same file.
|
||||||
|
///
|
||||||
|
/// The split opens a file as two independent copies, not two views of one document: each
|
||||||
|
/// pane has its own annotations, undo stack and dirty flag, so whichever saves last wins.
|
||||||
|
/// This is the guard on that.
|
||||||
|
///
|
||||||
|
/// Compares OriginalFile, not CurrentFile: crop and rotate swap the working file out to a
|
||||||
|
/// temp path, so CurrentFile can differ between two panes showing the same document.</summary>
|
||||||
|
private bool OtherPaneHasDirtyCopyOf(string? originalFile)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(originalFile)) return false;
|
||||||
|
var other = ReferenceEquals(ActiveViewer, Viewer) ? ViewerB : Viewer;
|
||||||
|
other.CaptureActiveIfAny(); // its live dirty flag may not be folded into its session yet
|
||||||
|
return other.SessionsRef.Any(s => s.IsDirty
|
||||||
|
&& string.Equals(s.OriginalFile, originalFile, System.StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Place the restored session list into pane A. The startup restore builds the list
|
||||||
|
/// itself rather than going through EnsureInitialSession, so it hands the result over.
|
||||||
|
/// Per-pane restore is not implemented; everything reopens in pane A.</summary>
|
||||||
|
private void SetRestoredSessions(IEnumerable<Controls.PdfViewer.DocumentSession> sessions,
|
||||||
|
Controls.PdfViewer.DocumentSession? active)
|
||||||
|
=> Viewer.SetSessionsExt(sessions, active);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Shapes;
|
||||||
|
using PdfSharpCore.Pdf;
|
||||||
|
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The window's half of the viewer bridge. Read this alongside
|
||||||
|
/// Controls/PdfViewer.Bridge.cs, which is the other end of every line here.
|
||||||
|
///
|
||||||
|
/// TWO DIRECTIONS, and they are kept apart on purpose:
|
||||||
|
///
|
||||||
|
/// INWARD - accessors the viewer reads. MainWindow's own members are private and a control
|
||||||
|
/// in another namespace cannot see them. Rather than widen ~40 fields in place and
|
||||||
|
/// scatter `internal` through fifteen files, each is exposed once, here, under a
|
||||||
|
/// name that says it is a bridge rather than an ordinary member. The private
|
||||||
|
/// fields stay private, so nothing else in the app gains reach by accident.
|
||||||
|
///
|
||||||
|
/// OUTWARD - focused-pane routing for shared toolbar and keyboard commands. Per-viewer
|
||||||
|
/// editing and document state stays on PdfViewer; only window-owned commands
|
||||||
|
/// cross this boundary.
|
||||||
|
///
|
||||||
|
/// XAML handlers remain on MainWindow because WPF resolves them against the XAML root.
|
||||||
|
/// </summary>
|
||||||
|
public partial class MainWindow
|
||||||
|
{
|
||||||
|
// ══ INWARD: chrome the viewer reads ═════════════════════════════════════════════════
|
||||||
|
// PageList is not here - x:Name fields are generated internal, so the control already
|
||||||
|
// sees it. These four are hand-declared private fields assigned from FindName, so they
|
||||||
|
// are not.
|
||||||
|
/// <summary>The window's ONE PageList selection delegate. Handed out rather than rebuilt
|
||||||
|
/// because SyncCurrentPageTo detaches and reattaches it - a method group would make a new
|
||||||
|
/// delegate per call and the -= would quietly remove nothing.</summary>
|
||||||
|
internal SelectionChangedEventHandler PageListSelectionHandler
|
||||||
|
=> _pageListSelectionHandler ??= PageList_SelectionChanged;
|
||||||
|
private SelectionChangedEventHandler? _pageListSelectionHandler;
|
||||||
|
|
||||||
|
// ══ INWARD: per-document state (group B - goes when the viewer owns its session) ═════
|
||||||
|
// Settable: the xref-repair path in Annotations.cs, which lives in the viewer, reopens the
|
||||||
|
// document and re-points the temp file.
|
||||||
|
// Reads OUT of the focused pane rather than exposing a window field: the session list
|
||||||
|
// belongs to the viewer. Callers still spell it `_active` - see the alias below.
|
||||||
|
internal Controls.PdfViewer.DocumentSession? ActiveSession => ActiveViewer.ActiveSessionRef;
|
||||||
|
private Controls.PdfViewer.DocumentSession? _active => ActiveViewer.ActiveSessionRef;
|
||||||
|
// Reads OUT of the control rather than exposing a window field: the link-rect map belongs
|
||||||
|
// to the viewer, alongside Links.cs. ContextMenu.cs and FileOperations.cs still call it by
|
||||||
|
// this name.
|
||||||
|
private Dictionary<int, List<LinkInfo>> _continuousLinks => ActiveViewer.ContinuousLinks;
|
||||||
|
|
||||||
|
// Live gesture state, shared with the annotation and crop tools that have not moved yet.
|
||||||
|
|
||||||
|
// ══ OUTWARD: the viewer's members, under the names the window already calls ══════════
|
||||||
|
// Signatures mirror the originals exactly, defaults included, so no call site changed.
|
||||||
|
private System.Threading.Tasks.Task RenderContinuousPages(int centerPage) => ActiveViewer.RenderContinuousPages(centerPage);
|
||||||
|
private void BootstrapDocumentView(int initialPage, bool autoFit, bool restoreFitMode = false)
|
||||||
|
=> ActiveViewer.BootstrapDocumentView(initialPage, autoFit, restoreFitMode);
|
||||||
|
private void RefreshPageView(int pageIndex) => ActiveViewer.RefreshPageView(pageIndex);
|
||||||
|
private void ScrollContinuousToPage(int pageIndex) => ActiveViewer.ScrollContinuousToPage(pageIndex);
|
||||||
|
|
||||||
|
private void StartRerenderTimer() => ActiveViewer.StartRerenderTimer();
|
||||||
|
private void SetZoom(double level) => ActiveViewer.SetZoom(level);
|
||||||
|
private void SetTrueZoom(double trueZoom) => ActiveViewer.SetTrueZoom(trueZoom);
|
||||||
|
private void GridZoomStep(bool zoomOut) => ActiveViewer.GridZoomStep(zoomOut);
|
||||||
|
private double GridZoomForN(int n) => ActiveViewer.GridZoomForN(n);
|
||||||
|
private double DisplayZoomPct() => ActiveViewer.DisplayZoomPct();
|
||||||
|
private void SyncZoomBox() => ActiveViewer.SyncZoomBox();
|
||||||
|
private void FitToWidth(bool lite = false) => ActiveViewer.FitToWidth(lite);
|
||||||
|
private void FitToPage(bool lite = false) => ActiveViewer.FitToPage(lite);
|
||||||
|
|
||||||
|
private void SetViewMode(ViewMode mode) => ActiveViewer.SetViewMode(mode);
|
||||||
|
private void SelectViewMode(ViewMode mode) => ActiveViewer.SelectViewMode(mode);
|
||||||
|
private void ApplyViewMode(ViewMode mode) => ActiveViewer.ApplyViewMode(mode);
|
||||||
|
private ViewMode? _pendingViewMode { get => ActiveViewer.PendingViewMode; set => ActiveViewer.PendingViewMode = value; }
|
||||||
|
|
||||||
|
private bool NavigatePageStep(int direction) => ActiveViewer.NavigatePageStep(direction);
|
||||||
|
private void NavigatePageByWheel(int delta) => ActiveViewer.NavigatePageByWheel(delta);
|
||||||
|
|
||||||
|
private int _gridColumns { get => ActiveViewer.GridColumns; set => ActiveViewer.GridColumns = value; }
|
||||||
|
|
||||||
|
private void BuildPrimaryTile() => ActiveViewer.BuildPrimaryTile();
|
||||||
|
private void PagePreviewPanel_SizeChanged(object sender, SizeChangedEventArgs e)
|
||||||
|
=> ActiveViewer.PagePreviewPanel_SizeChanged(sender, e);
|
||||||
|
|
||||||
|
// Bound from MainWindow.xaml (the zoom toolbar stays on the window) and from
|
||||||
|
// ContextMenu.cs, so these three cannot simply live on the control.
|
||||||
|
private void ZoomIn_Click(object sender, RoutedEventArgs e) => ActiveViewer.ZoomIn_Click(sender, e);
|
||||||
|
private void ZoomOut_Click(object sender, RoutedEventArgs e) => ActiveViewer.ZoomOut_Click(sender, e);
|
||||||
|
|
||||||
|
// ACTIVEVIEWER, like every other stub in this file - this one said `Viewer` (pane A,
|
||||||
|
// hardcoded) and was the split's cross-zoom bug: FocusPane(B) -> SyncZoomBox writes the
|
||||||
|
// shared box -> SelectionChanged -> this stub ran PANE A's handler, which FitToWidth'd
|
||||||
|
// pane A against pane B's document (proven by zoomtrace, 2026-08-01).
|
||||||
|
// NULL-CONDITIONAL, and it must stay that way. ZoomBox declares
|
||||||
|
// <ComboBoxItem Tag="1.0" IsSelected="True"> (MainWindow.xaml), so SelectionChanged fires
|
||||||
|
// while InitializeComponent is still walking the tree - ActiveViewer is not assigned until
|
||||||
|
// InitSplitPanes runs after it, so the `?.` no-ops the mid-parse fire exactly like the old
|
||||||
|
// `_zoomBox?.SelectedItem` guard did. Click handlers do not need this - a click cannot
|
||||||
|
// happen mid-parse.
|
||||||
|
private void ZoomBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
=> ActiveViewer?.ZoomBox_SelectionChanged(sender, e);
|
||||||
|
|
||||||
|
// Wheel over the zoom dropdown nudges the zoom like Ctrl+scroll. Null-conditional for the
|
||||||
|
// same mid-parse reason as SelectionChanged above.
|
||||||
|
private void ZoomBox_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
|
||||||
|
=> ActiveViewer?.ZoomBoxWheel(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Windows.Controls;
|
||||||
|
using KillerPDF.Controls;
|
||||||
|
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
// The element names MainWindow.xaml used to generate, now that the document pane is a control
|
||||||
|
// (Controls/PdfViewer.xaml).
|
||||||
|
//
|
||||||
|
// Same trick as the ViewerState forwarding: the names stay, the storage moves. Every
|
||||||
|
// existing call site - DocPaneBorder.CornerRadius in Tabs.cs, DocPaneShadow.Visibility in
|
||||||
|
// FullScreen.cs, PagePreviewPanel and MarqueeLayer across the render and annotation code -
|
||||||
|
// compiles untouched.
|
||||||
|
//
|
||||||
|
// Get-only is correct here: callers mutate the ELEMENT (its margin, radius, visibility), never
|
||||||
|
// rebind the reference.
|
||||||
|
//
|
||||||
|
// NOTE the one thing that could NOT be forwarded: the card's MARGIN. It lives on the control
|
||||||
|
// now, not on PaneBorder, because the control is what the layout positions. ApplySidebarSide
|
||||||
|
// and ApplyFullScreen set ActiveViewer.Margin directly.
|
||||||
|
public partial class MainWindow
|
||||||
|
{
|
||||||
|
private Border DocPaneShadow => ActiveViewer.PaneShadowBorder;
|
||||||
|
private Border DocPaneBorder => ActiveViewer.PaneCardBorder;
|
||||||
|
private Grid DocPaneContent => ActiveViewer.ContentHost;
|
||||||
|
// 7 bare uses in Tabs.cs and Viewport.cs. Note this is a SEPARATE member from the
|
||||||
|
// underscore-prefixed _pageContentGrid, which forwards to ViewerState - the code uses both
|
||||||
|
// spellings, so both have to resolve.
|
||||||
|
private Grid PageContentGrid => ActiveViewer.PageGrid;
|
||||||
|
private ScrollViewer PagePreviewPanel => ActiveViewer.PreviewScroller;
|
||||||
|
private Border DropZone => ActiveViewer.DropSurface;
|
||||||
|
private Border RecentFilesBox => ActiveViewer.RecentBox;
|
||||||
|
private ItemsControl RecentFilesList => ActiveViewer.RecentList;
|
||||||
|
private Canvas MarqueeLayer => ActiveViewer.Marquee;
|
||||||
|
private Border DocSurfacePad => ActiveViewer.SurfacePad;
|
||||||
|
private System.Windows.Media.ImageBrush GrainBrush => ActiveViewer.Grain;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
using KillerPDF.Features;
|
||||||
|
using KillerPDF.Controls;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
// MainWindow's half of IViewerHost.
|
||||||
|
//
|
||||||
|
// Explicit implementation throughout, matching Shell/About.cs: MainWindow's own members are
|
||||||
|
// private, and a private member cannot implement an interface. Forwarding explicitly satisfies
|
||||||
|
// the contract without widening anything to public - a change nobody asked for.
|
||||||
|
//
|
||||||
|
// IShellServices (Window / Loc / SetStatus) is already implemented in Shell/About.cs and is
|
||||||
|
// inherited through IViewerHost, so it is deliberately NOT repeated here.
|
||||||
|
public partial class MainWindow : IViewerHost
|
||||||
|
{
|
||||||
|
void IViewerHost.MarkDirty(bool dirty) => MarkDirty(dirty);
|
||||||
|
|
||||||
|
void IViewerHost.SetTool(EditTool tool) => SetTool(tool);
|
||||||
|
bool IViewerHost.SidebarShowingOutlines => _sidebarShowingOutlines;
|
||||||
|
void IViewerHost.PopulateRecentFilesList(PdfViewer viewer) => PopulateRecentFilesList(viewer);
|
||||||
|
void IViewerHost.SwitchSidebarToPagesTab() => SwitchSidebarToPagesTab();
|
||||||
|
void IViewerHost.SyncSidebarToDocState(bool hasDoc, bool startup)
|
||||||
|
=> SyncSidebarToDocState(hasDoc, startup);
|
||||||
|
void IViewerHost.OpenFile(string path) => OpenFile(path);
|
||||||
|
void IViewerHost.UpdateFooterFade() => UpdateFooterFade();
|
||||||
|
void IViewerHost.UpdateTabStripFade() => UpdateTabStripFade();
|
||||||
|
System.Windows.Controls.Border? IViewerHost.SearchBar => _searchBar;
|
||||||
|
Features.SearchController IViewerHost.Search => Search;
|
||||||
|
System.Windows.Controls.TextBlock IViewerHost.FileNameLabel => FileNameLabel;
|
||||||
|
System.Windows.Controls.TreeView IViewerHost.OutlineTree => OutlineTree;
|
||||||
|
System.Windows.Controls.Button IViewerHost.SidebarOutlinesTab => SidebarOutlinesTab;
|
||||||
|
System.Windows.Controls.TextBlock IViewerHost.StatusText => StatusText;
|
||||||
|
FrameworkElement IViewerHost.ShortcutOverlay => ShortcutOverlay;
|
||||||
|
System.Windows.Controls.CheckBox IViewerHost.LinkConfirmCheck => LinkConfirmCheck;
|
||||||
|
System.Windows.Controls.ContextMenu IViewerHost.MakeThemedMenu() => MakeThemedMenu();
|
||||||
|
void IViewerHost.CloseSearchBar() => CloseSearchBar();
|
||||||
|
void IViewerHost.HideSignaturePopup() => HideSignaturePopup();
|
||||||
|
void IViewerHost.SaveTempAndReload(bool keepAnnotations, bool preserveZoom)
|
||||||
|
=> SaveTempAndReload(keepAnnotations, preserveZoom);
|
||||||
|
void IViewerHost.RecordNavJump() => RecordNavJump();
|
||||||
|
PageAnnotation? IViewerHost.PairPartner(PageAnnotation annotation) => PairPartner(annotation);
|
||||||
|
void IViewerHost.RenderStamps(int page) => RenderStamps(page);
|
||||||
|
void IViewerHost.OpenStampTool() => OpenStampTool();
|
||||||
|
bool IViewerHost.StampHitTest(int page, Point position) => StampHitTest(page, position);
|
||||||
|
void IViewerHost.ApplySearchHighlights(int page, System.Windows.Controls.Canvas canvas)
|
||||||
|
=> ApplySearchHighlights(page, canvas);
|
||||||
|
void IViewerHost.HighlightSearchResultsOnCurrentPage() => HighlightSearchResultsOnCurrentPage();
|
||||||
|
void IViewerHost.ShowTextSettings() => ShowTextSettings();
|
||||||
|
void IViewerHost.HideTextSettings() => HideTextSettings();
|
||||||
|
void IViewerHost.StyleEditBox(System.Windows.Controls.TextBox textBox) => StyleEditBox(textBox);
|
||||||
|
void IViewerHost.ApplyTextStyleToSelection() => ApplyTextStyleToSelection();
|
||||||
|
void IViewerHost.ShowDrawSettings(EditTool tool) => ShowDrawSettings(tool);
|
||||||
|
void IViewerHost.HideDrawSettings() => HideDrawSettings();
|
||||||
|
System.Windows.Controls.Border IViewerHost.MakeBarGrip(int dotCount) => MakeBarGrip(dotCount);
|
||||||
|
FrameworkElement IViewerHost.BuildBarHost(FrameworkElement content) => BuildBarHost(content);
|
||||||
|
void IViewerHost.PlaceAnnotationBar(System.Windows.Controls.Border bar,
|
||||||
|
System.Windows.Controls.Border grip, bool fadeIn) => PlaceAnnotationBar(bar, grip, fadeIn);
|
||||||
|
void IViewerHost.PlaceImageFromDialog(Point position, int pageIndex)
|
||||||
|
=> PlaceImageFromDialog(position, pageIndex);
|
||||||
|
void IViewerHost.PlaceSignature(Point position, int pageIndex) => PlaceSignature(position, pageIndex);
|
||||||
|
void IViewerHost.ShowSignaturePopup() => ShowSignaturePopup();
|
||||||
|
void IViewerHost.FillSignField(bool initials, int objectNumber, int pageIndex,
|
||||||
|
double x, double y, double width, double height)
|
||||||
|
=> FillSignField(initials, objectNumber, pageIndex, x, y, width, height);
|
||||||
|
void IViewerHost.ShapeToolMouseDown(int pageIndex, Point position, MouseButtonEventArgs e)
|
||||||
|
=> ShapeToolMouseDown(pageIndex, position, e);
|
||||||
|
void IViewerHost.CommitShapeDrag(int pageIndex) => CommitShapeDrag(pageIndex);
|
||||||
|
void IViewerHost.UpdateShapePolyRubber(MouseEventArgs e) => UpdateShapePolyRubber(e);
|
||||||
|
void IViewerHost.OcrRegion(int pageIndex, Rect canvasBounds) => OcrRegion(pageIndex, canvasBounds);
|
||||||
|
void IViewerHost.ShowShortcutsOverlayExclusive() => ShowShortcutsOverlayExclusive();
|
||||||
|
System.Windows.Media.SolidColorBrush IViewerHost.SwatchDimBorder => _swatchDimBorder;
|
||||||
|
PageAnnotation? IViewerHost.CloneAnnotation(PageAnnotation annotation) => CloneAnnotation(annotation);
|
||||||
|
System.Windows.TextDecorationCollection? IViewerHost.BuildDecorations(bool underline, bool strike)
|
||||||
|
=> BuildDecorations(underline, strike);
|
||||||
|
System.Windows.Media.Effects.DropShadowEffect IViewerHost.AnnotBarShadow() => AnnotBarShadow();
|
||||||
|
void IViewerHost.FadeOverlayOut(UIElement element) => FadeOverlayOut(element);
|
||||||
|
void IViewerHost.FadeOutAndRemoveBar(System.Windows.Controls.Border? bar) => FadeOutAndRemoveBar(bar);
|
||||||
|
PdfSharpCore.Pdf.PdfItem IViewerHost.DerefItem(PdfSharpCore.Pdf.PdfItem item) => DerefItem(item);
|
||||||
|
string IViewerHost.WordsToText(System.Collections.Generic.IEnumerable<UglyToad.PdfPig.Content.Word> words)
|
||||||
|
=> WordsToText(words);
|
||||||
|
System.Windows.Controls.MenuItem IViewerHost.MakeMenuItem(string header, RoutedEventHandler click,
|
||||||
|
string? gesture, string? glyph) => MakeMenuItem(header, click, gesture, glyph);
|
||||||
|
|
||||||
|
bool IViewerHost.FullScreen => _fullScreen;
|
||||||
|
bool IViewerHost.VerticalScrollVisible
|
||||||
|
{
|
||||||
|
get => _vScrollVisible;
|
||||||
|
set => _vScrollVisible = value;
|
||||||
|
}
|
||||||
|
bool IViewerHost.SpaceHeld => _spaceHeld;
|
||||||
|
void IViewerHost.RepositionAnnotationBars() => RepositionAnnotationBars();
|
||||||
|
void IViewerHost.PopulateContextMenu(PdfViewer viewer, Point point, int pageIndex)
|
||||||
|
=> ((IViewerHost)this).RunWithViewerContext(viewer, () => PopulateContextMenu(point, pageIndex));
|
||||||
|
void IViewerHost.RefreshPageList(PdfViewer viewer)
|
||||||
|
=> ((IViewerHost)this).RunWithViewerContext(viewer, RefreshPageList);
|
||||||
|
void IViewerHost.LoadOutlines(PdfViewer viewer)
|
||||||
|
=> ((IViewerHost)this).RunWithViewerContext(viewer, LoadOutlines);
|
||||||
|
Cursor IViewerHost.CursorForTool(EditTool tool) => CursorForTool(tool);
|
||||||
|
|
||||||
|
// ---- Focused-viewer notifications ----------------------------------------------------
|
||||||
|
// Single pane today, so these just drive the existing chrome directly. When there are two,
|
||||||
|
// each body gains a "is the caller the focused viewer?" guard - the sidebar, page list and
|
||||||
|
// status line follow focus rather than whichever pane happened to update last. Keeping the
|
||||||
|
// calls routed through here means that guard lands in three known places instead of being
|
||||||
|
// hunted through 84 PageList call sites. (BACKLOG.md, group C.)
|
||||||
|
|
||||||
|
void IViewerHost.ViewerPageChanged(PdfViewer viewer, int pageIndex)
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(ActiveViewer, viewer)) return;
|
||||||
|
// Direct assignment, because that is what the 84 existing call sites do - there is no
|
||||||
|
// SyncPageListSelection helper today. The guard avoids re-entering the selection
|
||||||
|
// handler when the list already agrees.
|
||||||
|
if (pageIndex < 0 || PageList is null) return;
|
||||||
|
if (PageList.SelectedIndex != pageIndex) PageList.SelectedIndex = pageIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
void IViewerHost.EnsureSidebarPageVisible(PdfViewer viewer, int pageIndex)
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(ActiveViewer, viewer) || pageIndex < 0 || pageIndex >= PageList.Items.Count) return;
|
||||||
|
PageList.ScrollIntoView(PageList.Items[pageIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void IViewerHost.ScrollSidebar(PdfViewer viewer, double delta)
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(ActiveViewer, viewer)) return;
|
||||||
|
var scroller = FindSidebarDescendant<System.Windows.Controls.ScrollViewer>(PageList);
|
||||||
|
scroller?.ScrollToVerticalOffset(scroller.VerticalOffset + delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
void IViewerHost.ClearSidebarPages(PdfViewer viewer)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(ActiveViewer, viewer)) PageList.ItemsSource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string IViewerHost.PageJumpText { get => _pageJumpBox.Text; set => _pageJumpBox.Text = value; }
|
||||||
|
bool IViewerHost.PageJumpEnabled { set => _pageJumpBox.IsEnabled = value; }
|
||||||
|
bool IViewerHost.CloseFileEnabled { set => _closeFileBtnRef.IsEnabled = value; }
|
||||||
|
string IViewerHost.PageTotalText { set => _pageTotalLabel.Text = value; }
|
||||||
|
void IViewerHost.SelectAllPageJumpText() => _pageJumpBox.SelectAll();
|
||||||
|
|
||||||
|
void IViewerHost.SyncZoomDisplay(string? fitTag, string displayText)
|
||||||
|
{
|
||||||
|
if (fitTag != null)
|
||||||
|
foreach (System.Windows.Controls.ComboBoxItem item in _zoomBox.Items)
|
||||||
|
if (item.Tag?.ToString() == fitTag) { _zoomBox.SelectedItem = item; return; }
|
||||||
|
foreach (System.Windows.Controls.ComboBoxItem item in _zoomBox.Items)
|
||||||
|
if (item.Content?.ToString() == displayText) { _zoomBox.SelectedItem = item; return; }
|
||||||
|
_zoomBox.SelectedItem = null;
|
||||||
|
_zoomBox.Text = displayText;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? IViewerHost.SelectedZoomTag
|
||||||
|
=> (_zoomBox.SelectedItem as System.Windows.Controls.ComboBoxItem)?.Tag?.ToString();
|
||||||
|
|
||||||
|
void IViewerHost.CollapseZoomTextSelection()
|
||||||
|
{
|
||||||
|
if (_zoomBox.Template?.FindName("PART_EditableTextBox", _zoomBox) is System.Windows.Controls.TextBox box)
|
||||||
|
box.Select(box.Text.Length, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncZoomBox reads the current zoom itself rather than taking one, so the parameter is
|
||||||
|
// unused today. It stays in the signature because with two panes the window has to know
|
||||||
|
// WHICH viewer's zoom changed before deciding whether the toolbar box should follow.
|
||||||
|
void IViewerHost.ViewerZoomChanged(double zoomLevel) => SyncZoomBox();
|
||||||
|
|
||||||
|
void IViewerHost.ViewerFocused()
|
||||||
|
{
|
||||||
|
// Nothing to do while there is one viewer. With two panes this moves the accent halo
|
||||||
|
// here and repoints the sidebar at the caller.
|
||||||
|
}
|
||||||
|
|
||||||
|
void IViewerHost.ViewerSizeChanged(PdfViewer viewer, object sender, SizeChangedEventArgs e)
|
||||||
|
=> DocPane_SizeChanged(sender, e);
|
||||||
|
|
||||||
|
void IViewerHost.ViewerDrop(PdfViewer viewer, object sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
FocusPane(viewer);
|
||||||
|
DropZone_Drop(sender, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
void IViewerHost.ViewerDragOver(object sender, DragEventArgs e)
|
||||||
|
=> DropZone_DragOver(sender, e);
|
||||||
|
|
||||||
|
void IViewerHost.ViewerDropZoneClick(object sender, MouseButtonEventArgs e)
|
||||||
|
=> DropZone_Click(sender, e);
|
||||||
|
|
||||||
|
void IViewerHost.ClearRecentFiles(object sender, MouseButtonEventArgs e)
|
||||||
|
=> RecentClearAll_Click(sender, e);
|
||||||
|
|
||||||
|
void IViewerHost.ViewerBackgroundRightClick(object sender, MouseButtonEventArgs e)
|
||||||
|
=> DocPaneBackground_RightClick(sender, e);
|
||||||
|
|
||||||
|
void IViewerHost.ViewerTabStripMouseDown(object sender, MouseButtonEventArgs e)
|
||||||
|
=> TitleBar_MouseLeftButtonDown(sender, e);
|
||||||
|
|
||||||
|
bool IViewerHost.IsViewerFocused(PdfViewer viewer)
|
||||||
|
=> ReferenceEquals(ActiveViewer, viewer);
|
||||||
|
|
||||||
|
bool IViewerHost.IsSplitView => _isSplit;
|
||||||
|
|
||||||
|
void IViewerHost.FocusViewer(PdfViewer viewer) => FocusPane(viewer);
|
||||||
|
|
||||||
|
bool IViewerHost.OtherViewerHasFile(PdfViewer viewer, string? originalFile)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(originalFile)) return false;
|
||||||
|
var other = ReferenceEquals(Viewer, viewer) ? ViewerB : Viewer;
|
||||||
|
return other.SessionsRef.Any(x => (x.Doc != null || x.DeferredPath != null)
|
||||||
|
&& string.Equals(x.OriginalFile, originalFile, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
PdfViewer? IViewerHost.TabDropTarget(PdfViewer source, MouseEventArgs e)
|
||||||
|
=> TabDropTargetPane(source, e);
|
||||||
|
|
||||||
|
void IViewerHost.UpdateTabDragFeedback(PdfViewer source,
|
||||||
|
PdfViewer.DocumentSession session, MouseEventArgs e, PdfViewer? target)
|
||||||
|
=> UpdateTabDragFeedback(source, session, e, target);
|
||||||
|
|
||||||
|
void IViewerHost.HideTabDragFeedback() => HideTabDragFeedback();
|
||||||
|
|
||||||
|
void IViewerHost.MoveTabToPane(PdfViewer source, PdfViewer target,
|
||||||
|
PdfViewer.DocumentSession session, MouseEventArgs e)
|
||||||
|
=> MoveTabToPane(source, target, session, e);
|
||||||
|
|
||||||
|
void IViewerHost.RunWithViewerContext(PdfViewer viewer, Action work)
|
||||||
|
{
|
||||||
|
var focused = ActiveViewer;
|
||||||
|
focused.CaptureActiveIfAny();
|
||||||
|
var previous = SwapActiveViewer(viewer);
|
||||||
|
try { work(); }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SwapActiveViewer(previous);
|
||||||
|
focused.RestoreActiveFieldsOnly();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using PdfSharpCore.Pdf;
|
||||||
|
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Outward stubs: the annotation, text, crop, form and link members under the names the rest of
|
||||||
|
/// the window already calls them by.
|
||||||
|
///
|
||||||
|
/// The point of this file is that ~170 call sites across ContextMenu.cs, TextSettingsBar.cs,
|
||||||
|
/// KeyboardShortcuts.cs, FileOperations.cs, Tabs.cs, Signing.cs, Shapes.cs, Search.cs,
|
||||||
|
/// SidebarOutline.cs, Stamps.cs, ToolSelection.cs, TempReload.cs, Rotate.cs, Ocr.cs and
|
||||||
|
/// DirtyTracking.cs need no changes at all.
|
||||||
|
///
|
||||||
|
/// THE XAML ONES ARE NOT OPTIONAL. WPF resolves Click="Undo_Click" against the code-behind of
|
||||||
|
/// the XAML ROOT - MainWindow - not against whichever class the method ended up in. Eleven
|
||||||
|
/// handlers in MainWindow.xaml point at members that now live in the viewer, and without these
|
||||||
|
/// declarations InitializeComponent throws XamlParseException before the window ever appears.
|
||||||
|
/// Verified against MainWindow.xaml rather than remembered: Undo_Click (2 bindings),
|
||||||
|
/// ClearAllAnnotations_Click (2), PageJumpBox_KeyDown, PageJumpBox_GotFocus,
|
||||||
|
/// PageList_SelectionChanged, ShortcutHelp_Click, ShortcutOverlay_MouseLeftButtonDown (2),
|
||||||
|
/// ShortcutOverlayCard_MouseLeftButtonDown (2), ShortcutOverlayClose_Click,
|
||||||
|
/// Hyperlink_RequestNavigate.
|
||||||
|
/// </summary>
|
||||||
|
public partial class MainWindow
|
||||||
|
{
|
||||||
|
// ── Annotations ──────────────────────────────────────────────────────────────────────
|
||||||
|
private void RenderAllAnnotations(int pageIndex) => ActiveViewer.RenderAllAnnotations(pageIndex);
|
||||||
|
private void ClearSelection() => ActiveViewer.ClearSelection();
|
||||||
|
private void ClearTextSelection() => ActiveViewer.ClearTextSelection();
|
||||||
|
private SolidColorBrush AccentBrush(byte alpha = 255) => ActiveViewer.AccentBrush(alpha);
|
||||||
|
private void AddAnnotation(PageAnnotation a) => ActiveViewer.AddAnnotationExt(a);
|
||||||
|
private Rect AnnotBounds(PageAnnotation a) => ActiveViewer.AnnotBoundsExt(a);
|
||||||
|
private static Point AnnotGetPos(PageAnnotation a) => Controls.PdfViewer.AnnotGetPosExt(a);
|
||||||
|
private static void AnnotSetPos(PageAnnotation a, Point pos) => Controls.PdfViewer.AnnotSetPosExt(a, pos);
|
||||||
|
private Point ClampAnnotPos(PageAnnotation a) => ActiveViewer.ClampAnnotPosExt(a);
|
||||||
|
private bool HitTestAnnotation(PageAnnotation a, Point pos, out Rect bounds)
|
||||||
|
=> ActiveViewer.HitTestAnnotationExt(a, pos, out bounds);
|
||||||
|
private static bool IsDraggable(PageAnnotation a) => Controls.PdfViewer.IsDraggableExt(a);
|
||||||
|
private void SelectAnnotation(PageAnnotation a, Rect bounds) => ActiveViewer.SelectAnnotationExt(a, bounds);
|
||||||
|
private void ToggleMultiSelect(PageAnnotation a, Rect bounds, Canvas canvas)
|
||||||
|
=> ActiveViewer.ToggleMultiSelectExt(a, bounds, canvas);
|
||||||
|
private void SelectGroup(PageAnnotation lead) => ActiveViewer.SelectGroupExt(lead);
|
||||||
|
private PageAnnotation? SelectedPaired() => ActiveViewer.SelectedPairedExt();
|
||||||
|
private int SelectionCount() => ActiveViewer.SelectionCountExt();
|
||||||
|
private void ReattachSelectionVisuals() => ActiveViewer.ReattachSelectionVisualsExt();
|
||||||
|
private void UnpairSelected() => ActiveViewer.UnpairSelectedExt();
|
||||||
|
private void GroupSelected() => ActiveViewer.GroupSelectedExt();
|
||||||
|
private void UngroupAnnotation(PageAnnotation a) => ActiveViewer.UngroupAnnotationExt(a);
|
||||||
|
private void RemoveFromGroup(PageAnnotation a) => ActiveViewer.RemoveFromGroupExt(a);
|
||||||
|
private void DeleteSelected() => ActiveViewer.DeleteSelectedExt();
|
||||||
|
private bool SelectAllAnnotations() => ActiveViewer.SelectAllAnnotationsExt();
|
||||||
|
private void HideBrushPreview() => ActiveViewer.HideBrushPreviewExt();
|
||||||
|
private void FinishStuckGesture() => ActiveViewer.FinishStuckGestureExt();
|
||||||
|
private void RefreshSelectionAccent() => ActiveViewer.RefreshSelectionAccentExt();
|
||||||
|
|
||||||
|
// ── Page canvases ────────────────────────────────────────────────────────────────────
|
||||||
|
private Canvas CanvasForPage(int page) => ActiveViewer.CanvasForPageExt(page);
|
||||||
|
private Canvas? VisibleCanvasForPage(int page) => ActiveViewer.VisibleCanvasForPageExt(page);
|
||||||
|
private IEnumerable<Canvas> AllPageCanvases() => ActiveViewer.AllPageCanvasesExt();
|
||||||
|
|
||||||
|
// ── Undo ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
private void PushDocUndo() => ActiveViewer.PushDocUndoExt();
|
||||||
|
private void PushPageSnapshotUndo(int pageIdx) => ActiveViewer.PushPageSnapshotUndoExt(pageIdx);
|
||||||
|
|
||||||
|
// ── Text editing ─────────────────────────────────────────────────────────────────────
|
||||||
|
private void CommitActiveTextBox() => ActiveViewer.CommitActiveTextBoxExt();
|
||||||
|
private void RemoveTextEditHandles() => ActiveViewer.RemoveTextEditHandlesExt();
|
||||||
|
private void EditTextAtPosition(Point canvasPos, int pageIdx) => ActiveViewer.EditTextAtPositionExt(canvasPos, pageIdx);
|
||||||
|
private void PlaceTextBox(Point pos, int pageIdx) => ActiveViewer.PlaceTextBoxExt(pos, pageIdx);
|
||||||
|
private Brush TextEditBackground() => ActiveViewer.TextEditBackgroundExt();
|
||||||
|
private static ControlTemplate FlatTextBoxTemplate() => Controls.PdfViewer.FlatTextBoxTemplateExt();
|
||||||
|
|
||||||
|
// ── Text selection ───────────────────────────────────────────────────────────────────
|
||||||
|
private void CopySelectedText() => ActiveViewer.CopySelectedTextExt();
|
||||||
|
private void SelectAllText() => ActiveViewer.SelectAllTextExt();
|
||||||
|
|
||||||
|
// ── Crop ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
private void ApplyCrop(int[] pageIndices) => ActiveViewer.ApplyCropExt(pageIndices);
|
||||||
|
private void HideCropConfirmBar() => ActiveViewer.HideCropConfirmBarExt();
|
||||||
|
private void ShowDefaultCropBox() => ActiveViewer.ShowDefaultCropBoxExt();
|
||||||
|
private void RebuildCropBarForLocale() => ActiveViewer.RebuildCropBarForLocaleExt();
|
||||||
|
|
||||||
|
// ── Links ────────────────────────────────────────────────────────────────────────────
|
||||||
|
private void CloseLinkPdfiumDoc() => ActiveViewer.CloseLinkPdfiumDocExt();
|
||||||
|
private void AddLinkMenuItems(ContextMenu menu, object target, int annotIndex, int pageIndex)
|
||||||
|
=> ActiveViewer.AddLinkMenuItemsExt(menu, target, annotIndex, pageIndex);
|
||||||
|
private int? ResolveDest(PdfItem? destItem) => ActiveViewer.ResolveDestExt(destItem);
|
||||||
|
private const double LinkHitPad = Controls.PdfViewer.LinkHitPadShared;
|
||||||
|
internal const string ConfirmLinksSetting = Controls.PdfViewer.ConfirmLinksSetting;
|
||||||
|
|
||||||
|
// ── Save paths ───────────────────────────────────────────────────────────────────────
|
||||||
|
private void DrawAnnotationsOnDocument(int? onlyPage = null) => ActiveViewer.DrawAnnotationsOnDocumentExt(onlyPage);
|
||||||
|
private void WriteFormValuesToDocument() => ActiveViewer.WriteFormValuesToDocumentExt();
|
||||||
|
|
||||||
|
// ── Bound from MainWindow.xaml - see the class comment, these are load-bearing ───────
|
||||||
|
private void Undo_Click(object sender, RoutedEventArgs e) => ActiveViewer.UndoClickExt(sender, e);
|
||||||
|
private void Redo_Click(object sender, RoutedEventArgs e) => ActiveViewer.RedoClickExt(sender, e);
|
||||||
|
private void ClearAnnotations_Click(object sender, RoutedEventArgs e) => ActiveViewer.ClearAnnotationsClickExt(sender, e);
|
||||||
|
private void ClearAllAnnotations_Click(object sender, RoutedEventArgs e) => ActiveViewer.ClearAllAnnotationsClickExt(sender, e);
|
||||||
|
private void PageJumpBox_KeyDown(object sender, KeyEventArgs e) => ActiveViewer.PageJumpBoxKeyDownExt(sender, e);
|
||||||
|
private void PageJumpBox_GotFocus(object sender, RoutedEventArgs e) => ActiveViewer.PageJumpBoxGotFocusExt(sender, e);
|
||||||
|
private void PageList_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
=> ActiveViewer.PageListSelectionChangedExt(sender, e);
|
||||||
|
private void ShortcutHelp_Click(object sender, RoutedEventArgs e) => ActiveViewer.ShortcutHelpClickExt(sender, e);
|
||||||
|
private void ShortcutOverlay_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||||
|
=> FadeOverlayOut(ShortcutOverlay);
|
||||||
|
private void ShortcutOverlayCard_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||||
|
=> e.Handled = true;
|
||||||
|
private void ShortcutOverlayClose_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
FadeOverlayOut(ShortcutOverlay);
|
||||||
|
}
|
||||||
|
private void Hyperlink_RequestNavigate(object sender, System.Windows.Navigation.RequestNavigateEventArgs e)
|
||||||
|
=> ActiveViewer.HyperlinkRequestNavigateExt(sender, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
|
||||||
|
<Costura>
|
||||||
|
<!-- pdfium.dll is a native P/Invoke dependency brought in by Docnet.Core.
|
||||||
|
Embedding it so the single EXE stays truly portable. -->
|
||||||
|
<Unmanaged64Assemblies>
|
||||||
|
pdfium
|
||||||
|
</Unmanaged64Assemblies>
|
||||||
|
</Costura>
|
||||||
|
</Weavers>
|
||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||||
|
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. -->
|
||||||
|
<xs:element name="Weavers">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:all>
|
||||||
|
<xs:element name="Costura" minOccurs="0" maxOccurs="1">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:all>
|
||||||
|
<xs:element minOccurs="0" maxOccurs="1" name="ExcludeAssemblies" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:element>
|
||||||
|
<xs:element minOccurs="0" maxOccurs="1" name="IncludeAssemblies" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>A list of assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:element>
|
||||||
|
<xs:element minOccurs="0" maxOccurs="1" name="ExcludeRuntimeAssemblies" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:element>
|
||||||
|
<xs:element minOccurs="0" maxOccurs="1" name="IncludeRuntimeAssemblies" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:element>
|
||||||
|
<xs:element minOccurs="0" maxOccurs="1" name="ExcludeRuntimes" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>A list of runtimes to exclude from the default action of "embed all Copy Local references", delimited with line breaks</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:element>
|
||||||
|
<xs:element minOccurs="0" maxOccurs="1" name="IncludeRuntimes" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>A list of runtimes names to include from the default action of "embed all Copy Local references", delimited with line breaks.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:element>
|
||||||
|
<xs:element minOccurs="0" maxOccurs="1" name="Unmanaged32Assemblies" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>Obsolete, use UnmanagedWinX86Assemblies instead</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:element>
|
||||||
|
<xs:element minOccurs="0" maxOccurs="1" name="UnmanagedWinX86Assemblies" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>A list of unmanaged X86 (32 bit) assembly names to include, delimited with line breaks.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:element>
|
||||||
|
<xs:element minOccurs="0" maxOccurs="1" name="Unmanaged64Assemblies" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>Obsolete, use UnmanagedWinX64Assemblies instead.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:element>
|
||||||
|
<xs:element minOccurs="0" maxOccurs="1" name="UnmanagedWinX64Assemblies" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>A list of unmanaged X64 (64 bit) assembly names to include, delimited with line breaks.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:element>
|
||||||
|
<xs:element minOccurs="0" maxOccurs="1" name="UnmanagedWinArm64Assemblies" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>A list of unmanaged Arm64 (64 bit) assembly names to include, delimited with line breaks.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:element>
|
||||||
|
<xs:element minOccurs="0" maxOccurs="1" name="PreloadOrder" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>The order of preloaded assemblies, delimited with line breaks.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:element>
|
||||||
|
</xs:all>
|
||||||
|
<xs:attribute name="CreateTemporaryAssemblies" type="xs:boolean">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>This will copy embedded files to disk before loading them into memory. This is helpful for some scenarios that expected an assembly to be loaded from a physical file.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
<xs:attribute name="IncludeDebugSymbols" type="xs:boolean">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>Controls if .pdbs for reference assemblies are also embedded.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
<xs:attribute name="IncludeRuntimeReferences" type="xs:boolean">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>Controls if runtime assemblies are also embedded.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
<xs:attribute name="UseRuntimeReferencePaths" type="xs:boolean">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>Controls whether the runtime assemblies are embedded with their full path or only with their assembly name.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
<xs:attribute name="DisableCompression" type="xs:boolean">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>Embedded assemblies are compressed by default, and uncompressed when they are loaded. You can turn compression off with this option.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
<xs:attribute name="DisableCleanup" type="xs:boolean">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>As part of Costura, embedded assemblies are no longer included as part of the build. This cleanup can be turned off.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
<xs:attribute name="DisableEventSubscription" type="xs:boolean">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>The attach method no longer subscribes to the `AppDomain.AssemblyResolve` (.NET 4.x) and `AssemblyLoadContext.Resolving` (.NET 6.0+) events.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
<xs:attribute name="LoadAtModuleInit" type="xs:boolean">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>Costura by default will load as part of the module initialization. This flag disables that behavior. Make sure you call CosturaUtility.Initialize() somewhere in your code.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
<xs:attribute name="IgnoreSatelliteAssemblies" type="xs:boolean">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>Costura will by default use assemblies with a name like 'resources.dll' as a satellite resource and prepend the output path. This flag disables that behavior.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
<xs:attribute name="ExcludeAssemblies" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with |</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
<xs:attribute name="IncludeAssemblies" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>A list of assembly names to include from the default action of "embed all Copy Local references", delimited with |.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
<xs:attribute name="ExcludeRuntimeAssemblies" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with |</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
<xs:attribute name="IncludeRuntimeAssemblies" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with |.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
<xs:attribute name="Unmanaged32Assemblies" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>Obsolete, use UnmanagedWinX86Assemblies instead</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
<xs:attribute name="UnmanagedWinX86Assemblies" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>A list of unmanaged X86 (32 bit) assembly names to include, delimited with |.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
<xs:attribute name="Unmanaged64Assemblies" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>Obsolete, use UnmanagedWinX64Assemblies instead</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
<xs:attribute name="UnmanagedWinX64Assemblies" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>A list of unmanaged X64 (64 bit) assembly names to include, delimited with |.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
<xs:attribute name="UnmanagedWinArm64Assemblies" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>A list of unmanaged Arm64 (64 bit) assembly names to include, delimited with |.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
<xs:attribute name="PreloadOrder" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>The order of preloaded assemblies, delimited with |.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
</xs:all>
|
||||||
|
<xs:attribute name="VerifyAssembly" type="xs:boolean">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
<xs:attribute name="VerifyIgnoreCodes" type="xs:string">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
<xs:attribute name="GenerateXsd" type="xs:boolean">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
</xs:schema>
|
||||||
Binary file not shown.
@@ -0,0 +1,114 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using KillerPDF.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KillerPDF.Tests;
|
||||||
|
|
||||||
|
// #169: rotating a page must keep its overlay annotations and turn them with the page.
|
||||||
|
// The mapping runs in render-dim space; a +90 turn takes frame (W, H) to (H, W), so
|
||||||
|
// round-trip tests swap the frame between passes exactly as the live reload does.
|
||||||
|
public sealed class AnnotationRotateTests
|
||||||
|
{
|
||||||
|
private const double W = 800, H = 600;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Highlight_QuarterTurn_TurnsWithTheContent()
|
||||||
|
{
|
||||||
|
var ha = new HighlightAnnotation { Bounds = new Rect(10, 20, 100, 40) };
|
||||||
|
AnnotationRotate.Remap([ha], 90, W, H);
|
||||||
|
|
||||||
|
// Corners (10,20)-(110,60) map to (580,10)-(540,110): the region swaps its axes.
|
||||||
|
Assert.Equal(540, ha.Bounds.X, 3);
|
||||||
|
Assert.Equal(10, ha.Bounds.Y, 3);
|
||||||
|
Assert.Equal(40, ha.Bounds.Width, 3);
|
||||||
|
Assert.Equal(100, ha.Bounds.Height, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Highlight_FourQuarterTurns_IsIdentity()
|
||||||
|
{
|
||||||
|
var start = new Rect(10, 20, 100, 40);
|
||||||
|
var ha = new HighlightAnnotation { Bounds = start };
|
||||||
|
AnnotationRotate.Remap([ha], 90, W, H);
|
||||||
|
AnnotationRotate.Remap([ha], 90, H, W);
|
||||||
|
AnnotationRotate.Remap([ha], 90, W, H);
|
||||||
|
AnnotationRotate.Remap([ha], 90, H, W);
|
||||||
|
Assert.Equal(start, ha.Bounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Highlight_TurnThenCounterTurn_IsIdentity()
|
||||||
|
{
|
||||||
|
var start = new Rect(33, 44, 55, 66);
|
||||||
|
var ha = new HighlightAnnotation { Bounds = start };
|
||||||
|
AnnotationRotate.Remap([ha], 90, W, H);
|
||||||
|
AnnotationRotate.Remap([ha], -90, H, W);
|
||||||
|
Assert.Equal(start, ha.Bounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Highlight_TwoQuarterTurns_MatchHalfTurn()
|
||||||
|
{
|
||||||
|
var a = new HighlightAnnotation { Bounds = new Rect(10, 20, 100, 40) };
|
||||||
|
var b = new HighlightAnnotation { Bounds = new Rect(10, 20, 100, 40) };
|
||||||
|
AnnotationRotate.Remap([a], 90, W, H);
|
||||||
|
AnnotationRotate.Remap([a], 90, H, W);
|
||||||
|
AnnotationRotate.Remap([b], 180, W, H);
|
||||||
|
Assert.Equal(b.Bounds, a.Bounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TextBox_KeepsSize_CenterFollowsThePage()
|
||||||
|
{
|
||||||
|
var ta = new TextAnnotation { Position = new Point(10, 70), Width = 100, Height = 40 };
|
||||||
|
AnnotationRotate.Remap([ta], 90, W, H);
|
||||||
|
|
||||||
|
// Center (60,90) maps to (510,60); the box keeps 100x40 around it.
|
||||||
|
Assert.Equal(460, ta.Position.X, 3);
|
||||||
|
Assert.Equal(40, ta.Position.Y, 3);
|
||||||
|
Assert.Equal(100, ta.Width, 3);
|
||||||
|
Assert.Equal(40, ta.Height, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UprightTextBoxNearLongEdge_IsClampedInsideRotatedPage()
|
||||||
|
{
|
||||||
|
// Fully inside the old 800x600 page. Keeping this tall box upright after a clockwise turn
|
||||||
|
// would put its bottom at 870 on the new 600x800 page unless the remap clamps it.
|
||||||
|
var ta = new TextAnnotation { Position = new Point(750, 100), Width = 40, Height = 200 };
|
||||||
|
AnnotationRotate.Remap([ta], 90, W, H);
|
||||||
|
|
||||||
|
Assert.Equal(380, ta.Position.X, 3);
|
||||||
|
Assert.Equal(600, ta.Position.Y, 3);
|
||||||
|
Assert.InRange(ta.Position.X + ta.Width, 0, H);
|
||||||
|
Assert.InRange(ta.Position.Y + ta.Height, 0, W);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UprightPlacedItemNearLongEdge_IsClampedInsideRotatedPage()
|
||||||
|
{
|
||||||
|
var image = new ImageAnnotation
|
||||||
|
{
|
||||||
|
Position = new Point(750, 100),
|
||||||
|
SourceWidth = 40,
|
||||||
|
SourceHeight = 200,
|
||||||
|
Scale = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
AnnotationRotate.Remap([image], 90, W, H);
|
||||||
|
|
||||||
|
Assert.Equal(380, image.Position.X, 3);
|
||||||
|
Assert.Equal(600, image.Position.Y, 3);
|
||||||
|
Assert.InRange(image.Position.X + image.SourceWidth * image.Scale, 0, H);
|
||||||
|
Assert.InRange(image.Position.Y + image.SourceHeight * image.Scale, 0, W);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InkPoints_MapPerPoint()
|
||||||
|
{
|
||||||
|
var ia = new InkAnnotation { Points = [new Point(0, 0), new Point(100, 50)] };
|
||||||
|
AnnotationRotate.Remap([ia], 90, W, H);
|
||||||
|
Assert.Equal(new Point(H, 0), ia.Points[0]);
|
||||||
|
Assert.Equal(new Point(H - 50, 100), ia.Points[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KillerPDF.Tests
|
||||||
|
{
|
||||||
|
public class FlyoutPlacementTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void LeftRailUsesBottomLeftContentCorner()
|
||||||
|
{
|
||||||
|
var placement = FlyoutPlacement.PaneCorner(
|
||||||
|
new Size(244, 460), new Size(1200, 700), alignRight: false)[0];
|
||||||
|
|
||||||
|
Assert.Equal(new Point(-16, 260), placement.Point);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RightRailMirrorsFlyoutToBottomRightContentCorner()
|
||||||
|
{
|
||||||
|
var placement = FlyoutPlacement.PaneCorner(
|
||||||
|
new Size(244, 460), new Size(1200, 700), alignRight: true)[0];
|
||||||
|
|
||||||
|
Assert.Equal(new Point(972, 260), placement.Point);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TallFlyoutStaysBelowToolbarOnEitherSide()
|
||||||
|
{
|
||||||
|
var left = FlyoutPlacement.PaneCorner(
|
||||||
|
new Size(244, 800), new Size(1200, 700), alignRight: false)[0];
|
||||||
|
var right = FlyoutPlacement.PaneCorner(
|
||||||
|
new Size(244, 800), new Size(1200, 700), alignRight: true)[0];
|
||||||
|
|
||||||
|
Assert.Equal(0, left.Point.Y);
|
||||||
|
Assert.Equal(0, right.Point.Y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net48</TargetFramework>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<!-- Inert on PolySharp 1.15.0, whose targets never make this property compiler-visible.
|
||||||
|
Set here so bumping this project to 1.16.0 cannot reintroduce the CS8336 reserved-name
|
||||||
|
break on net48 that KillerPDF.csproj works around. -->
|
||||||
|
<PolySharpUseEmbeddedAttributeForGeneratedTypes>false</PolySharpUseEmbeddedAttributeForGeneratedTypes>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<UseWPF>true</UseWPF>
|
||||||
|
<!-- Linked data-only model fields are populated by the application, not these focused tests. -->
|
||||||
|
<NoWarn>$(NoWarn);CS0649</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
|
<PackageReference Include="PolySharp" Version="1.15.0">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="PdfPig" Version="0.1.14" />
|
||||||
|
<PackageReference Include="System.Text.Json" Version="10.0.11" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="..\Models\EditingTypes.cs" Link="Models\EditingTypes.cs" />
|
||||||
|
<Compile Include="..\Services\SignatureStore.cs" Link="Services\SignatureStore.cs" />
|
||||||
|
<Compile Include="..\Services\SearchService.cs" Link="Services\SearchService.cs" />
|
||||||
|
<Compile Include="..\Services\TextRunService.cs" Link="Services\TextRunService.cs" />
|
||||||
|
<Compile Include="..\Services\ProtocolRegistrar.cs" Link="Services\ProtocolRegistrar.cs" />
|
||||||
|
<Compile Include="..\Services\PerspectiveWarp.cs" Link="Services\PerspectiveWarp.cs" />
|
||||||
|
<Compile Include="..\Services\PdfFontStyle.cs" Link="Services\PdfFontStyle.cs" />
|
||||||
|
<Compile Include="..\Services\PdfScrub.cs" Link="Services\PdfScrub.cs" />
|
||||||
|
<Compile Include="..\Services\PdfBurn.cs" Link="Services\PdfBurn.cs" />
|
||||||
|
<Compile Include="..\Services\AnnotationRotate.cs" Link="Services\AnnotationRotate.cs" />
|
||||||
|
<Compile Include="..\Services\WheelPageFlipGate.cs" Link="Services\WheelPageFlipGate.cs" />
|
||||||
|
<Compile Include="..\Services\FontCoverage.cs" Link="Services\FontCoverage.cs" />
|
||||||
|
<Compile Include="..\Services\CmapCoverage.cs" Link="Services\CmapCoverage.cs" />
|
||||||
|
<Compile Include="..\Services\PdfFonts.cs" Link="Services\PdfFonts.cs" />
|
||||||
|
<Compile Include="..\Models\StampModels.cs" Link="Models\StampModels.cs" />
|
||||||
|
<Compile Include="..\Controls\FlyoutPlacement.cs" Link="Controls\FlyoutPlacement.cs" />
|
||||||
|
<!-- Data only, by design: OcrLanguages.cs itself reaches App and OcrNativeBootstrap, which a
|
||||||
|
catalog check has no business dragging in. See OcrCatalogTests. -->
|
||||||
|
<Compile Include="..\Services\OcrCatalog.cs" Link="Services\OcrCatalog.cs" />
|
||||||
|
<!-- Data only, same reasoning as OcrCatalog: the shortcut table is deliberately free of WPF
|
||||||
|
and of MainWindow so the invariants can be checked without standing up a window. -->
|
||||||
|
<Compile Include="..\Services\ShortcutTable.cs" Link="Services\ShortcutTable.cs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="System.Drawing" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\third_party\PdfSharpCore\PdfSharpCore.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KillerPDF.Tests;
|
||||||
|
|
||||||
|
public sealed class LocalizationParityTests
|
||||||
|
{
|
||||||
|
private static readonly string StringsDirectory = FindStringsDirectory();
|
||||||
|
private static readonly XNamespace Xaml = "http://schemas.microsoft.com/winfx/2006/xaml";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EveryLocaleHasTheEnglishKeysAndPlaceholders()
|
||||||
|
{
|
||||||
|
var english = ReadStrings(Path.Combine(StringsDirectory, "en-US.xaml"));
|
||||||
|
foreach (var file in Directory.GetFiles(StringsDirectory, "*.xaml"))
|
||||||
|
{
|
||||||
|
var localized = ReadStrings(file);
|
||||||
|
Assert.True(english.Keys.OrderBy(x => x).SequenceEqual(localized.Keys.OrderBy(x => x)),
|
||||||
|
$"{Path.GetFileName(file)} does not contain exactly the English resource-key set.");
|
||||||
|
|
||||||
|
foreach (var key in english.Keys)
|
||||||
|
{
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(localized[key]),
|
||||||
|
$"{Path.GetFileName(file)} has an empty value for {key}.");
|
||||||
|
Assert.True(Placeholders(english[key]).SequenceEqual(Placeholders(localized[key])),
|
||||||
|
$"{Path.GetFileName(file)} has different placeholders for {key}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Issue227SharedSurfacesHaveResourceKeys()
|
||||||
|
{
|
||||||
|
var english = ReadStrings(Path.Combine(StringsDirectory, "en-US.xaml"));
|
||||||
|
string[] required =
|
||||||
|
[
|
||||||
|
"Str_Btn_OK", "Str_Btn_Yes", "Str_Btn_No", "Str_Btn_Cancel",
|
||||||
|
"Str_RecentMissing",
|
||||||
|
"Str_St_CopiedAnnotationOne", "Str_St_CopiedAnnotationMany",
|
||||||
|
"Str_St_PastedAnnotationOne", "Str_St_PastedAnnotationMany",
|
||||||
|
"Str_St_DeletedAnnotationOne", "Str_St_DeletedAnnotationMany",
|
||||||
|
"Str_Search_NoMatches", "Str_Search_Error", "Str_Search_Summary",
|
||||||
|
"Str_Search_PreviousTT", "Str_Search_NextTT", "Str_Search_CloseTT",
|
||||||
|
"Str_Busy_FlattenPage", "Str_Busy_ExportPage", "Str_Busy_CancelHint",
|
||||||
|
"Str_Busy_DownloadingMany"
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (string key in required)
|
||||||
|
Assert.True(english.ContainsKey(key), $"Missing #227 resource key: {key}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Issue227ReportedEnglishIsNotHardcodedInItsUiPaths()
|
||||||
|
{
|
||||||
|
string root = Directory.GetParent(StringsDirectory)!.FullName;
|
||||||
|
var checks = new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
["Controls/KillerDialog.cs"] = ["MakeBtn(\"Yes\"", "MakeBtn(\"No\"", "MakeBtn(\"Cancel\""],
|
||||||
|
["Shell/FileOperations.cs"] = [": \"missing\"", "\"Exporting\"", "\"Flattening\""],
|
||||||
|
["Shell/ContextMenu.cs"] = ["Copied 1 annotation", "Pasted 1 annotation"],
|
||||||
|
["Controls/Viewer/PdfViewer.Annotations.cs"] = ["Deleted selected annotation"],
|
||||||
|
["Shell/Search.cs"] = ["Previous Match (Shift+Enter)", "Next Match (Enter)", "Close (Esc)"],
|
||||||
|
["Features/Search/SearchController.cs"] = ["SetResultText(\"No matches\")", "SetResultText(\"Search error\")"],
|
||||||
|
["Services/OcrLanguages.cs"] = ["(Esc to cancel)"]
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var check in checks)
|
||||||
|
{
|
||||||
|
string source = File.ReadAllText(Path.Combine(root, check.Key.Replace('/', Path.DirectorySeparatorChar)));
|
||||||
|
foreach (string text in check.Value)
|
||||||
|
Assert.DoesNotContain(text, source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ReadStrings(string path) =>
|
||||||
|
XDocument.Load(path).Root!.Elements()
|
||||||
|
.Where(e => e.Attribute(Xaml + "Key") is not null)
|
||||||
|
.ToDictionary(e => e.Attribute(Xaml + "Key")!.Value, e => e.Value);
|
||||||
|
|
||||||
|
private static IEnumerable<string> Placeholders(string value) =>
|
||||||
|
Regex.Matches(value, @"\{\d+(?::[^}]*)?\}").Cast<Match>().Select(m => m.Value).OrderBy(x => x);
|
||||||
|
|
||||||
|
private static string FindStringsDirectory()
|
||||||
|
{
|
||||||
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null && !Directory.Exists(Path.Combine(directory.FullName, "Strings")))
|
||||||
|
directory = directory.Parent;
|
||||||
|
Assert.NotNull(directory);
|
||||||
|
return Path.Combine(directory!.FullName, "Strings");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using KillerPDF.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KillerPDF.Tests;
|
||||||
|
|
||||||
|
// The rule this file exists to enforce: OCR LANGUAGES TRACK INTERFACE LANGUAGES.
|
||||||
|
// If KillerPDF's UI is offered in a language, OCR is offered in it too. There is no such thing as
|
||||||
|
// an interface language whose text the app cannot read.
|
||||||
|
//
|
||||||
|
// Nothing enforced it before, so it drifted: the UI shipped hu-HU while the catalog stayed at
|
||||||
|
// eleven entries, and killerpdf.net went on telling people OCR covered ten languages and that
|
||||||
|
// Polish and Hungarian "do not require OCR models".
|
||||||
|
//
|
||||||
|
// These read the real Strings folder instead of a second hardcoded list, so adding a locale fails
|
||||||
|
// the build until its model is registered. release.ps1 runs the suite, so it cannot ship broken.
|
||||||
|
public sealed class OcrCatalogTests
|
||||||
|
{
|
||||||
|
// The test binary sits at KillerPDF.Tests\bin\<cfg>\net48; the repo root is the ancestor that
|
||||||
|
// holds Strings\. Walking up beats a pile of ..\..\.. that breaks whenever the layout moves.
|
||||||
|
private static string StringsDir()
|
||||||
|
{
|
||||||
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Strings")))
|
||||||
|
dir = dir.Parent;
|
||||||
|
Assert.True(dir != null, "could not locate the repo's Strings folder from " + AppContext.BaseDirectory);
|
||||||
|
return Path.Combine(dir!.FullName, "Strings");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] ShippedLocales() =>
|
||||||
|
Directory.GetFiles(StringsDir(), "*.xaml")
|
||||||
|
.Select(f => Path.GetFileNameWithoutExtension(f)!)
|
||||||
|
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheStringsFolderIsActuallyFound()
|
||||||
|
{
|
||||||
|
// Guards the guard: if the walk-up ever failed silently, every other test here would
|
||||||
|
// compare against an empty list and pass while checking nothing.
|
||||||
|
Assert.NotEmpty(ShippedLocales());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EveryShippedLocaleHasAnOcrModel()
|
||||||
|
{
|
||||||
|
var mapped = OcrCatalog.LocaleToCode.Select(x => x.Locale).ToArray();
|
||||||
|
var missing = ShippedLocales()
|
||||||
|
.Where(l => !mapped.Contains(l, StringComparer.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.True(missing.Length == 0,
|
||||||
|
"These locales ship an interface but have no OCR model in OcrCatalog.LocaleToCode: " +
|
||||||
|
string.Join(", ", missing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NoModelIsRegisteredForALocaleThatIsNotShipped()
|
||||||
|
{
|
||||||
|
var locales = ShippedLocales();
|
||||||
|
var stale = OcrCatalog.LocaleToCode
|
||||||
|
.Where(x => !locales.Contains(x.Locale, StringComparer.OrdinalIgnoreCase))
|
||||||
|
.Select(x => x.Locale + " -> " + x.Code)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.True(stale.Length == 0,
|
||||||
|
"These OCR entries point at locales no longer in Strings\\: " + string.Join(", ", stale));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CatalogAndLocaleMapAgree()
|
||||||
|
{
|
||||||
|
var catalog = OcrCatalog.Languages.Select(x => x.Code).OrderBy(c => c, StringComparer.Ordinal).ToArray();
|
||||||
|
var mapped = OcrCatalog.LocaleToCode.Select(x => x.Code).OrderBy(c => c, StringComparer.Ordinal).ToArray();
|
||||||
|
Assert.Equal(mapped, catalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CatalogHasNoDuplicatesAndNoBlanks()
|
||||||
|
{
|
||||||
|
var codes = OcrCatalog.Languages.Select(x => x.Code).ToArray();
|
||||||
|
Assert.Equal(codes.Length, codes.Distinct(StringComparer.Ordinal).Count());
|
||||||
|
Assert.All(OcrCatalog.Languages, e =>
|
||||||
|
{
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(e.Code));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(e.Name));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OcrLanguageCountEqualsInterfaceLanguageCount()
|
||||||
|
{
|
||||||
|
// The number killerpdf.net quotes in prose. Pinning it here is what stops the site and
|
||||||
|
// the app from disagreeing again.
|
||||||
|
Assert.Equal(ShippedLocales().Length, OcrCatalog.Languages.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("hu-HU", "hun")]
|
||||||
|
[InlineData("pl-PL", "pol")]
|
||||||
|
public void TheOnesThatWereMissingAreRegistered(string locale, string code)
|
||||||
|
{
|
||||||
|
Assert.Contains(OcrCatalog.LocaleToCode, x =>
|
||||||
|
string.Equals(x.Locale, locale, StringComparison.OrdinalIgnoreCase) && x.Code == code);
|
||||||
|
Assert.Contains(OcrCatalog.Languages, x => x.Code == code);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using PdfSharpCore.Pdf;
|
||||||
|
using PdfSharpCore.Pdf.IO;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KillerPDF.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Regression cover for bookmarks whose /Dest is a *named destination* rather than a literal
|
||||||
|
/// array. wkhtmltopdf writes /Dest /__WKANCHOR_n plus a flat catalog /Dests dictionary, and
|
||||||
|
/// since most HTML-to-PDF invoice generators are wkhtmltopdf underneath, that shape is common.
|
||||||
|
/// PdfSharpCore's PdfOutline.Initialize() used to hit Debug.Assert(false, "See what to do when
|
||||||
|
/// this happened.") on it - a modal dialog in Debug, a silently dead bookmark in Release.
|
||||||
|
/// </summary>
|
||||||
|
public class OutlineDestinationTests
|
||||||
|
{
|
||||||
|
const string DestName = "/__WKANCHOR_2";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Debug.Assert writes to a trace listener and shows a modal box - it never throws, so an
|
||||||
|
/// assert regression would HANG this test rather than fail it. Swap in a listener that
|
||||||
|
/// turns Fail into an exception.
|
||||||
|
/// </summary>
|
||||||
|
sealed class ThrowingListener : TraceListener
|
||||||
|
{
|
||||||
|
public override void Write(string? message) { }
|
||||||
|
public override void WriteLine(string? message) { }
|
||||||
|
public override void Fail(string? message) => throw new Xunit.Sdk.XunitException("Debug.Assert fired: " + message);
|
||||||
|
public override void Fail(string? message, string? detail) => throw new Xunit.Sdk.XunitException("Debug.Assert fired: " + message + " | " + detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpenOutlines_NamedDestination_DoesNotAssert()
|
||||||
|
{
|
||||||
|
var dir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||||
|
System.IO.Directory.CreateDirectory(dir);
|
||||||
|
var file = System.IO.Path.Combine(dir, "named_dest.pdf");
|
||||||
|
|
||||||
|
var original = new TraceListener[Trace.Listeners.Count];
|
||||||
|
Trace.Listeners.CopyTo(original, 0);
|
||||||
|
Trace.Listeners.Clear();
|
||||||
|
Trace.Listeners.Add(new ThrowingListener());
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
WriteNamedDestPdf(file);
|
||||||
|
|
||||||
|
var doc = PdfReader.Open(file, PdfDocumentOpenMode.Modify);
|
||||||
|
var outlines = doc.Outlines; // threw before the fix
|
||||||
|
|
||||||
|
Assert.Single(outlines);
|
||||||
|
Assert.Equal("Tax Invoice", outlines[0].Title);
|
||||||
|
|
||||||
|
// Guard the fixture itself: if a future PdfSharpCore writer rewrote the name into a
|
||||||
|
// literal array on save, this test would still pass while no longer covering the
|
||||||
|
// regression at all.
|
||||||
|
Assert.IsType<PdfName>(outlines[0].Elements.GetValue("/Dest"));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Trace.Listeners.Clear();
|
||||||
|
Trace.Listeners.AddRange(original);
|
||||||
|
System.IO.Directory.Delete(dir, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a one-page PDF with a single bookmark pointing at a named destination.
|
||||||
|
/// ponytail: the /Dests entry holds the destination array DIRECTLY; the invoice that
|
||||||
|
/// surfaced this held it indirectly. Both take the same /Dest-is-a-PdfName branch, which is
|
||||||
|
/// the regression under test. Make it indirect if that path ever breaks on its own.
|
||||||
|
/// </summary>
|
||||||
|
static void WriteNamedDestPdf(string path)
|
||||||
|
{
|
||||||
|
var doc = new PdfDocument();
|
||||||
|
doc.AddPage();
|
||||||
|
|
||||||
|
// [page /XYZ left top zoom] - integer page number, as wkhtmltopdf emits.
|
||||||
|
var dest = new PdfArray(doc,
|
||||||
|
new PdfInteger(0), new PdfName("/XYZ"),
|
||||||
|
new PdfInteger(0), new PdfInteger(800), new PdfInteger(0));
|
||||||
|
|
||||||
|
var dests = new PdfDictionary(doc);
|
||||||
|
dests.Elements[DestName] = dest;
|
||||||
|
doc.Internals.AddObject(dests);
|
||||||
|
|
||||||
|
var root = new PdfDictionary(doc);
|
||||||
|
root.Elements["/Type"] = new PdfName("/Outlines");
|
||||||
|
doc.Internals.AddObject(root);
|
||||||
|
|
||||||
|
var item = new PdfDictionary(doc);
|
||||||
|
item.Elements["/Title"] = new PdfString("Tax Invoice");
|
||||||
|
item.Elements["/Dest"] = new PdfName(DestName);
|
||||||
|
item.Elements.SetReference("/Parent", root);
|
||||||
|
doc.Internals.AddObject(item);
|
||||||
|
|
||||||
|
root.Elements.SetReference("/First", item);
|
||||||
|
root.Elements.SetReference("/Last", item);
|
||||||
|
|
||||||
|
doc.Internals.Catalog.Elements.SetReference("/Outlines", root);
|
||||||
|
doc.Internals.Catalog.Elements.SetReference("/Dests", dests);
|
||||||
|
|
||||||
|
doc.Save(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Windows;
|
||||||
|
using KillerPDF.Services;
|
||||||
|
using PdfSharpCore.Drawing;
|
||||||
|
using PdfSharpCore.Pdf;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KillerPDF.Tests;
|
||||||
|
|
||||||
|
// #169: content placed on a rotated page must burn where the user placed it. The burn draws in
|
||||||
|
// the VISUAL frame and maps back through a quarter-turn matrix. Two parts have regressed
|
||||||
|
// independently and each is pinned here by reading the saved content stream:
|
||||||
|
// 1. the scale basis - sx/sy must come from the visual page size, not the raw page box
|
||||||
|
// (a regression squeezes the rect by exactly the page's aspect ratio), and
|
||||||
|
// 2. the quarter-turn matrix reaching both burn paths (annotations AND stamps) - dropping
|
||||||
|
// it leaves the rect numbers right but the content turned 90 degrees on the page.
|
||||||
|
public sealed class PdfBurnRotationTests
|
||||||
|
{
|
||||||
|
// A5-ish landscape map on a portrait MediaBox, the shape from the #169 repro files.
|
||||||
|
private const double BoxW = 842, BoxH = 1191;
|
||||||
|
|
||||||
|
private static string BurnHighlightContent(int nativeRotate, Dictionary<int, int>? rotations,
|
||||||
|
double pageW, double pageH, int renderW, int renderH, Rect bounds)
|
||||||
|
{
|
||||||
|
using var doc = new PdfDocument();
|
||||||
|
doc.Options.NoCompression = true;
|
||||||
|
var page = doc.AddPage();
|
||||||
|
page.MediaBox = new PdfRectangle(new XPoint(0, 0), new XPoint(pageW, pageH));
|
||||||
|
if (nativeRotate != 0) page.Rotate = nativeRotate;
|
||||||
|
|
||||||
|
var annots = new Dictionary<int, List<PageAnnotation>>
|
||||||
|
{
|
||||||
|
[0] = [new HighlightAnnotation { PageIndex = 0, Bounds = bounds, Style = HighlightStyle.Fill }],
|
||||||
|
};
|
||||||
|
var dims = new Dictionary<int, (int w, int h)> { [0] = (renderW, renderH) };
|
||||||
|
PdfBurn.DrawAnnotationsIntoDoc(doc, annots, dims, null, rotations);
|
||||||
|
return SaveToText(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SaveToText(PdfDocument doc)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
doc.Save(ms, false);
|
||||||
|
|
||||||
|
// PdfSharpCore may Flate-compress content streams even when NoCompression is set;
|
||||||
|
// that option governs document structure, not every page stream. Inspect the decoded
|
||||||
|
// page content instead of depending on the writer's storage choice.
|
||||||
|
var content = doc.Pages[0].Contents.CreateSingleContent();
|
||||||
|
return Encoding.GetEncoding("ISO-8859-1").GetString(content.Stream.UnfilteredValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every `x y w h re` operator in the saved file, as (w, h).
|
||||||
|
private static List<(double w, double h)> RectSizes(string pdf)
|
||||||
|
{
|
||||||
|
var list = new List<(double, double)>();
|
||||||
|
foreach (Match m in Regex.Matches(pdf,
|
||||||
|
@"(-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) re"))
|
||||||
|
{
|
||||||
|
list.Add((double.Parse(m.Groups[3].Value, System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
double.Parse(m.Groups[4].Value, System.Globalization.CultureInfo.InvariantCulture)));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when the content carries a quarter-turn cm (a and d zero, b and c unit) - the visual-to-page
|
||||||
|
// mapping the rotated burn must emit. The unrotated base transform is axis-aligned and never matches.
|
||||||
|
private static bool HasQuarterTurnCm(string pdf)
|
||||||
|
{
|
||||||
|
foreach (Match m in Regex.Matches(pdf,
|
||||||
|
@"(-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) cm"))
|
||||||
|
{
|
||||||
|
double a = double.Parse(m.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
double b = double.Parse(m.Groups[2].Value, System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
double c = double.Parse(m.Groups[3].Value, System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
double d = double.Parse(m.Groups[4].Value, System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
if (Math.Abs(a) < 0.001 && Math.Abs(d) < 0.001 &&
|
||||||
|
Math.Abs(Math.Abs(b) - 1) < 0.001 && Math.Abs(Math.Abs(c) - 1) < 0.001)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnrotatedPage_BurnsAtRenderScale_NoTurn()
|
||||||
|
{
|
||||||
|
// Letter page rendered at 2x: a 300x60 canvas rect must burn as 150x30 points.
|
||||||
|
string pdf = BurnHighlightContent(0, null, 612, 792, 1224, 1584, new Rect(100, 200, 300, 60));
|
||||||
|
|
||||||
|
var rects = RectSizes(pdf);
|
||||||
|
var r = Assert.Single(rects);
|
||||||
|
Assert.Equal(150, r.w, 2);
|
||||||
|
Assert.Equal(30, r.h, 2);
|
||||||
|
Assert.False(HasQuarterTurnCm(pdf));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native /Rotate on a freshly opened file (the 1.7.1 fallback - the rotation map is empty),
|
||||||
|
// and both quarter turns.
|
||||||
|
[Theory]
|
||||||
|
[InlineData(90)]
|
||||||
|
[InlineData(270)]
|
||||||
|
public void NativeRotate_UsesVisualScaleAndTurns(int rotate)
|
||||||
|
{
|
||||||
|
// Visual frame is 1191x842, rendered at 2x. A 300x60 canvas rect must burn as 150x30.
|
||||||
|
// The #169 regression scaled against the raw 842x1191 box instead, which burns exactly
|
||||||
|
// 106.07x42.45 - the aspect-ratio squeeze from terada-d's measurements.
|
||||||
|
string pdf = BurnHighlightContent(rotate, null, BoxW, BoxH, 2382, 1684, new Rect(200, 400, 300, 60));
|
||||||
|
|
||||||
|
var rects = RectSizes(pdf);
|
||||||
|
var r = Assert.Single(rects);
|
||||||
|
Assert.Equal(150, r.w, 2);
|
||||||
|
Assert.Equal(30, r.h, 2);
|
||||||
|
Assert.True(HasQuarterTurnCm(pdf), "rotated burn emitted no quarter-turn cm - content will land turned 90 degrees");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InAppRotation_MapOverridesStrippedPage()
|
||||||
|
{
|
||||||
|
// In-app rotation: the working copy has /Rotate stripped to 0 and the angle lives in the
|
||||||
|
// shell's rotation map. The burn must honor the map exactly as it honors a native /Rotate.
|
||||||
|
var rotations = new Dictionary<int, int> { [0] = 90 };
|
||||||
|
string pdf = BurnHighlightContent(0, rotations, BoxW, BoxH, 2382, 1684, new Rect(200, 400, 300, 60));
|
||||||
|
|
||||||
|
var rects = RectSizes(pdf);
|
||||||
|
var r = Assert.Single(rects);
|
||||||
|
Assert.Equal(150, r.w, 2);
|
||||||
|
Assert.Equal(30, r.h, 2);
|
||||||
|
Assert.True(HasQuarterTurnCm(pdf));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rotate180_ScalesUnswappedAndTurns()
|
||||||
|
{
|
||||||
|
// 180 keeps the axes (no dimension swap) but still needs its half-turn mapping.
|
||||||
|
string pdf = BurnHighlightContent(180, null, BoxW, BoxH, 1684, 2382, new Rect(200, 400, 300, 60));
|
||||||
|
|
||||||
|
var rects = RectSizes(pdf);
|
||||||
|
var r = Assert.Single(rects);
|
||||||
|
Assert.Equal(150, r.w, 2);
|
||||||
|
Assert.Equal(30, r.h, 2);
|
||||||
|
// A half turn is (-1 0 0 -1) pre-flip; composed with the base flip it is axis-aligned,
|
||||||
|
// so assert on the rect numbers plus the annotation surviving - not on the cm shape.
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StampBurn_RotatedPage_GetsTheTurnToo()
|
||||||
|
{
|
||||||
|
// Stamps share the visual-frame helpers; the original #169 gap was the stamp burn never
|
||||||
|
// receiving the angle at all, so preview and output disagreed.
|
||||||
|
using var doc = new PdfDocument();
|
||||||
|
doc.Options.NoCompression = true;
|
||||||
|
var page = doc.AddPage();
|
||||||
|
page.MediaBox = new PdfRectangle(new XPoint(0, 0), new XPoint(BoxW, BoxH));
|
||||||
|
page.Rotate = 90;
|
||||||
|
|
||||||
|
var spec = new StampSpec { NumbersEnabled = true, Format = "{n} / {N}" };
|
||||||
|
PdfBurn.DrawStampsIntoDoc(doc, spec);
|
||||||
|
|
||||||
|
string pdf = SaveToText(doc);
|
||||||
|
Assert.True(HasQuarterTurnCm(pdf), "stamp burn on a rotated page emitted no quarter-turn cm");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using KillerPDF.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KillerPDF.Tests
|
||||||
|
{
|
||||||
|
public class PdfFontStyleTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
// #187: families are normalized to the installed Windows family the PostScript name
|
||||||
|
// means - the raw PS name resolves to nothing in WPF and read as "all formatting lost".
|
||||||
|
[InlineData("ABCDEF+Helvetica-Bold", "Arial", true, false)]
|
||||||
|
[InlineData("Helvetica-Oblique", "Arial", false, true)]
|
||||||
|
[InlineData("TimesNewRomanPS-BoldItalicMT", "Times New Roman", true, true)]
|
||||||
|
[InlineData("Arial-Regular", "Arial", false, false)]
|
||||||
|
[InlineData("ArialMT", "Arial", false, false)]
|
||||||
|
[InlineData("TimesNewRomanPSMT", "Times New Roman", false, false)]
|
||||||
|
[InlineData("CourierNewPS-BoldMT", "Courier New", true, false)]
|
||||||
|
[InlineData("GHIJKL+BookAntiqua", "Book Antiqua", false, false)]
|
||||||
|
public void DetectsFaceStyleFromPdfFontName(string source, string family, bool bold, bool italic)
|
||||||
|
{
|
||||||
|
var detected = PdfFontStyle.FromPdfName(source);
|
||||||
|
|
||||||
|
Assert.Equal(family, detected.Family);
|
||||||
|
Assert.Equal(bold, detected.Bold);
|
||||||
|
Assert.Equal(italic, detected.Italic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using KillerPDF.Services;
|
||||||
|
using PdfSharpCore.Drawing;
|
||||||
|
using PdfSharpCore.Pdf;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KillerPDF.Tests;
|
||||||
|
|
||||||
|
public sealed class PdfScrubTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ScrubDegenerateCropBoxes_RemovesCropOutsideMediaBox()
|
||||||
|
{
|
||||||
|
using var doc = new PdfDocument();
|
||||||
|
var page = doc.AddPage();
|
||||||
|
page.MediaBox = new PdfRectangle(new XPoint(0, 0), new XPoint(1191, 842));
|
||||||
|
page.CropBox = new PdfRectangle(new XPoint(0, 0), new XPoint(842, 1191));
|
||||||
|
page.Rotate = 90;
|
||||||
|
|
||||||
|
PdfScrub.ScrubDegenerateCropBoxes(doc);
|
||||||
|
|
||||||
|
Assert.Null(page.Elements["/CropBox"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScrubDegenerateCropBoxes_PreservesValidInsetCrop()
|
||||||
|
{
|
||||||
|
using var doc = new PdfDocument();
|
||||||
|
var page = doc.AddPage();
|
||||||
|
page.MediaBox = new PdfRectangle(new XPoint(0, 0), new XPoint(612, 792));
|
||||||
|
page.CropBox = new PdfRectangle(new XPoint(18, 24), new XPoint(594, 768));
|
||||||
|
|
||||||
|
PdfScrub.ScrubDegenerateCropBoxes(doc);
|
||||||
|
|
||||||
|
Assert.NotNull(page.Elements["/CropBox"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System.Text;
|
||||||
|
using UglyToad.PdfPig;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KillerPDF.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pins the PdfPig property that in-place text editing reads its font size from (#163).
|
||||||
|
/// Letter.FontSize is the size as written in the content stream, so a generator that emits
|
||||||
|
/// "/F1 1 Tf" and applies the scale through the text matrix reports 1 no matter how large the
|
||||||
|
/// glyphs actually draw. Detection used that value, which collapsed the replacement text onto
|
||||||
|
/// its lower clamp and read back as 3pt. Letter.PointSize is the size in points and is right
|
||||||
|
/// for both spellings, so these tests fail if a PdfPig upgrade changes either meaning.
|
||||||
|
/// </summary>
|
||||||
|
public class PdfTextSizeTests
|
||||||
|
{
|
||||||
|
// Two runs that draw at the same visual size: the first sized by Tf with an identity text
|
||||||
|
// matrix, the second sized entirely by a 12x text matrix.
|
||||||
|
const string Content =
|
||||||
|
"BT\n/F1 12 Tf\n1 0 0 1 72 700 Tm\n(Conventional) Tj\nET\n" +
|
||||||
|
"BT\n/F1 1 Tf\n12 0 0 12 72 660 Tm\n(Scaled) Tj\nET\n";
|
||||||
|
|
||||||
|
/// <summary>A single-page PDF holding <see cref="Content"/>, built by hand so the two runs
|
||||||
|
/// keep their exact Tf and Tm operands - no library would emit the second one.</summary>
|
||||||
|
static byte[] BuildPdf()
|
||||||
|
{
|
||||||
|
string[] objects =
|
||||||
|
[
|
||||||
|
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
|
||||||
|
"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
|
||||||
|
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] "
|
||||||
|
+ "/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n",
|
||||||
|
$"4 0 obj\n<< /Length {Content.Length} >>\nstream\n{Content}\nendstream\nendobj\n",
|
||||||
|
"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica "
|
||||||
|
+ "/Encoding /WinAnsiEncoding >>\nendobj\n",
|
||||||
|
];
|
||||||
|
|
||||||
|
var pdf = new StringBuilder("%PDF-1.4\n");
|
||||||
|
var offsets = new int[objects.Length];
|
||||||
|
for (int i = 0; i < objects.Length; i++)
|
||||||
|
{
|
||||||
|
offsets[i] = pdf.Length;
|
||||||
|
pdf.Append(objects[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
int startXref = pdf.Length;
|
||||||
|
pdf.Append($"xref\n0 {objects.Length + 1}\n0000000000 65535 f \n");
|
||||||
|
foreach (int offset in offsets)
|
||||||
|
pdf.Append($"{offset:D10} 00000 n \n");
|
||||||
|
pdf.Append($"trailer\n<< /Size {objects.Length + 1} /Root 1 0 R >>\n")
|
||||||
|
.Append($"startxref\n{startXref}\n%%EOF\n");
|
||||||
|
|
||||||
|
// The content is pure ASCII, so string length above is also the byte offset.
|
||||||
|
return Encoding.ASCII.GetBytes(pdf.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
static (double FontSize, double PointSize) FirstLetterOf(string word)
|
||||||
|
{
|
||||||
|
using var doc = PdfDocument.Open(BuildPdf());
|
||||||
|
var letter = doc.GetPage(1).GetWords().Single(w => w.Text == word).Letters[0];
|
||||||
|
return (letter.FontSize, letter.PointSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FontSize_MatchesPoints_OnlyWhenTfCarriesTheScale()
|
||||||
|
{
|
||||||
|
Assert.Equal(12, FirstLetterOf("Conventional").FontSize, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FontSize_ReportsTheRawOperand_WhenTheTextMatrixCarriesTheScale()
|
||||||
|
{
|
||||||
|
// The glyphs draw at 12pt, but Tf said 1. This is the value that produced #163.
|
||||||
|
Assert.Equal(1, FirstLetterOf("Scaled").FontSize, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PointSize_IsTheVisualSize_ForBothSpellings()
|
||||||
|
{
|
||||||
|
Assert.Equal(12, FirstLetterOf("Conventional").PointSize, 3);
|
||||||
|
Assert.Equal(12, FirstLetterOf("Scaled").PointSize, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using KillerPDF.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KillerPDF.Tests;
|
||||||
|
|
||||||
|
public sealed class PerspectiveWarpTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void IdentityCornersPreserveDimensionsAndCornerPixels()
|
||||||
|
{
|
||||||
|
byte[] pixels =
|
||||||
|
{
|
||||||
|
1,2,3,255, 4,5,6,255,
|
||||||
|
7,8,9,255, 10,11,12,255,
|
||||||
|
};
|
||||||
|
var source = BitmapSource.Create(2, 2, 96, 96, PixelFormats.Bgra32, null, pixels, 8);
|
||||||
|
Point[] corners = { new(0,0), new(1,0), new(1,1), new(0,1) };
|
||||||
|
|
||||||
|
var result = PerspectiveWarp.Apply(source, corners);
|
||||||
|
byte[] actual = new byte[16];
|
||||||
|
result.CopyPixels(actual, 8, 0);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.PixelWidth);
|
||||||
|
Assert.Equal(2, result.PixelHeight);
|
||||||
|
Assert.Equal(pixels, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RejectsCollapsedQuadrilateral()
|
||||||
|
{
|
||||||
|
var source = BitmapSource.Create(2, 2, 96, 96, PixelFormats.Bgra32, null, new byte[16], 8);
|
||||||
|
Point[] corners = { new(0,0), new(0,0), new(0,0), new(0,0) };
|
||||||
|
Assert.Throws<System.InvalidOperationException>(() => PerspectiveWarp.Apply(source, corners));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using KillerPDF.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KillerPDF.Tests;
|
||||||
|
|
||||||
|
public sealed class ProtocolRegistrarTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ParsesEncodedHttpsPdfUrl()
|
||||||
|
{
|
||||||
|
Assert.True(ProtocolRegistrar.TryGetTargetUrl(
|
||||||
|
"killerpdf://open?url=https%3A%2F%2Fexample.com%2Ffile.pdf%3Fx%3D1", out var target));
|
||||||
|
Assert.Equal("https://example.com/file.pdf?x=1", target!.AbsoluteUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("killerpdf://open?url=http%3A%2F%2Fexample.com%2Ffile.pdf")]
|
||||||
|
[InlineData("killerpdf://open?url=file%3A%2F%2Fc%3A%2Fsecret.pdf")]
|
||||||
|
[InlineData("killerpdf://wrong?url=https%3A%2F%2Fexample.com%2Ffile.pdf")]
|
||||||
|
[InlineData("https://example.com/file.pdf")]
|
||||||
|
public void RejectsUnsafeOrUnrelatedLaunches(string value)
|
||||||
|
=> Assert.False(ProtocolRegistrar.TryGetTargetUrl(value, out _));
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using KillerPDF.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KillerPDF.Tests
|
||||||
|
{
|
||||||
|
public class SearchServiceTests
|
||||||
|
{
|
||||||
|
private readonly SearchService _svc = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Search_EmptyQuery_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var result = _svc.Search("irrelevant.pdf", "");
|
||||||
|
Assert.Empty(result.ResultPages);
|
||||||
|
Assert.Equal(0, result.TotalHits);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Search_WhitespaceQuery_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var result = _svc.Search("irrelevant.pdf", " ");
|
||||||
|
Assert.Empty(result.ResultPages);
|
||||||
|
Assert.Equal(0, result.TotalHits);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Search_EmptyFilePath_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var result = _svc.Search("", "hello");
|
||||||
|
Assert.Empty(result.ResultPages);
|
||||||
|
Assert.Equal(0, result.TotalHits);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Search_MissingFile_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
// Should not throw; non-existent file produces no results.
|
||||||
|
var result = _svc.Search(@"C:\does\not\exist.pdf", "hello");
|
||||||
|
Assert.Empty(result.ResultPages);
|
||||||
|
Assert.Equal(0, result.TotalHits);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SearchResult_PageRects_EmptyByDefault()
|
||||||
|
{
|
||||||
|
var result = new SearchResult();
|
||||||
|
Assert.Empty(result.PageRects);
|
||||||
|
Assert.Empty(result.ResultPages);
|
||||||
|
Assert.Equal(0, result.TotalHits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using KillerPDF;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KillerPDF.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Invariants of the one shortcut table (Services/ShortcutTable.cs).
|
||||||
|
///
|
||||||
|
/// The table was unified in 1.7.5 from two hand maintained copies that had quietly drifted:
|
||||||
|
/// Alt+M existed in the list and not on the keyboard map, Home and End were captioned
|
||||||
|
/// differently in each view, and Ctrl+B was documented as bold in one section of the list and
|
||||||
|
/// as the sidebar in another while the code did neither consistently. Unifying removes the
|
||||||
|
/// opportunity for that; these tests are what keep it removed.
|
||||||
|
///
|
||||||
|
/// No WPF here on purpose. The table is plain data, which is why the csproj links the single
|
||||||
|
/// file rather than referencing the app, the same arrangement OcrCatalogTests uses.
|
||||||
|
/// </summary>
|
||||||
|
public class ShortcutTableTests
|
||||||
|
{
|
||||||
|
private static readonly string StringsDir =
|
||||||
|
Path.Combine(RepoRoot(), "Strings");
|
||||||
|
|
||||||
|
private static string RepoRoot()
|
||||||
|
{
|
||||||
|
// Walk up from the test binary until the folder holding Strings\en-US.xaml appears.
|
||||||
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "Strings", "en-US.xaml")))
|
||||||
|
dir = dir.Parent;
|
||||||
|
Assert.True(dir != null, "could not locate the repo root from the test output folder");
|
||||||
|
return dir!.FullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HashSet<string> EnglishKeys()
|
||||||
|
{
|
||||||
|
var doc = XDocument.Load(Path.Combine(StringsDir, "en-US.xaml"));
|
||||||
|
XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml";
|
||||||
|
return doc.Root!.Elements()
|
||||||
|
.Select(e => (string?)e.Attribute(x + "Key"))
|
||||||
|
.Where(k => k != null)
|
||||||
|
.Select(k => k!)
|
||||||
|
.ToHashSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// THE one that matters. Two bindings claiming the same key on the same layer means the
|
||||||
|
/// keyboard map silently shows whichever was declared last, which is exactly how Ctrl+B
|
||||||
|
/// managed to mean two different things without anyone noticing.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void NoKeyIsClaimedTwiceOnTheSameLayer()
|
||||||
|
{
|
||||||
|
var duplicates = ShortcutTable.AllCapClaims()
|
||||||
|
.GroupBy(c => c)
|
||||||
|
.Where(g => g.Count() > 1)
|
||||||
|
.Select(g => g.Key)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.True(duplicates.Count == 0,
|
||||||
|
"these keys are claimed by more than one binding: " + string.Join(", ", duplicates));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Every cap the table lights has to exist on the drawn board, or the binding is
|
||||||
|
/// invisible: the map only renders ids present in KbRows. Alt+M was missing the other way
|
||||||
|
/// round, absent from the table while the list advertised it.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void EveryCapExistsOnTheDrawnKeyboard()
|
||||||
|
{
|
||||||
|
// KbRows lives in the WPF half, so the ids are mirrored here deliberately: this test is
|
||||||
|
// the thing that fails if the two ever disagree, which is the point.
|
||||||
|
var board = new HashSet<string>
|
||||||
|
{
|
||||||
|
"Esc","F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12",
|
||||||
|
"Grave","D1","D2","D3","D4","D5","D6","D7","D8","D9","D0","Minus","Equals","Back",
|
||||||
|
"Ins","Home","PgUp",
|
||||||
|
"Tab","Q","W","E","R","T","Y","U","I","O","P","LBr","RBr","Bslash","Del","End","PgDn",
|
||||||
|
"Caps","A","S","D","F","G","H","J","K","L","Semi","Quote","Enter",
|
||||||
|
"Shift","Z","X","C","V","B","N","M","Comma","Period","Slash","RShift","Up",
|
||||||
|
"Ctrl","Win","Alt","Space","RAlt","Menu","RCtrl","Left","Down","Right",
|
||||||
|
};
|
||||||
|
|
||||||
|
var missing = ShortcutTable.KsAll
|
||||||
|
.SelectMany(b => b.Caps)
|
||||||
|
.Select(c => c.Id)
|
||||||
|
.Distinct()
|
||||||
|
.Where(id => !board.Contains(id))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.True(missing.Count == 0,
|
||||||
|
"these cap ids are not on the drawn keyboard: " + string.Join(", ", missing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EveryLabelKeyExistsInEnglish()
|
||||||
|
{
|
||||||
|
var english = EnglishKeys();
|
||||||
|
var missing = ShortcutTable.KsAll
|
||||||
|
.SelectMany(b => new[] { b.LabelKey }.Concat(b.Caps.Select(c => c.LabelKey)))
|
||||||
|
.Where(k => k.Length > 0)
|
||||||
|
.Distinct()
|
||||||
|
.Where(k => !english.Contains(k))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.True(missing.Count == 0,
|
||||||
|
"these resource keys are referenced but not defined in en-US.xaml: " + string.Join(", ", missing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EveryBindingBelongsToADeclaredSection()
|
||||||
|
{
|
||||||
|
var groups = ShortcutTable.KsGroups.Select(g => g.Cat).ToHashSet();
|
||||||
|
var orphans = ShortcutTable.KsAll.Select(b => b.Cat).Distinct()
|
||||||
|
.Where(c => !groups.Contains(c)).ToList();
|
||||||
|
|
||||||
|
Assert.True(orphans.Count == 0,
|
||||||
|
"these categories have bindings but no section: " + string.Join(", ", orphans));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A section with no bindings would render as a heading over nothing.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void EverySectionHasAtLeastOneBinding()
|
||||||
|
{
|
||||||
|
var used = ShortcutTable.KsAll.Select(b => b.Cat).ToHashSet();
|
||||||
|
var empty = ShortcutTable.KsGroups.Select(g => g.Cat).Where(c => !used.Contains(c)).ToList();
|
||||||
|
|
||||||
|
Assert.True(empty.Count == 0,
|
||||||
|
"these sections would render empty: " + string.Join(", ", empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The layer prefix is the only thing separating Shift+F3 from F3. A typo in the
|
||||||
|
/// prefix silently lands the cap on the base layer, so the parse gets its own check.</summary>
|
||||||
|
// Layer passed by name: KbLayer is internal, and an InlineData parameter has to be at least
|
||||||
|
// as accessible as the public test method.
|
||||||
|
[Theory]
|
||||||
|
[InlineData("F3", "Base", "F3")]
|
||||||
|
[InlineData("Ctrl:O", "Ctrl", "O")]
|
||||||
|
[InlineData("CtrlShift:D1", "CtrlShift", "D1")]
|
||||||
|
[InlineData("Shift:F9", "Shift", "F9")]
|
||||||
|
[InlineData("Alt:M", "Alt", "M")]
|
||||||
|
public void CapParsesItsLayerPrefix(string id, string layerName, string bare)
|
||||||
|
{
|
||||||
|
var cap = ShortcutTable.Cap(id);
|
||||||
|
Assert.Equal(layerName, cap.Layer.ToString());
|
||||||
|
Assert.Equal(bare, cap.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A cap without its own label inherits the row's; one with a label keeps it. That
|
||||||
|
/// is what lets "Home / End" stay one row while Home and End caption separately.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void CapLabelOverridesTheRowAndOtherwiseInherits()
|
||||||
|
{
|
||||||
|
var map = ShortcutTable.BuildMap();
|
||||||
|
|
||||||
|
Assert.Equal("Str_Kb_FirstPage", map[KbLayer.Base]["Home"].Label);
|
||||||
|
Assert.Equal("Str_Kb_LastPage", map[KbLayer.Base]["End"].Label);
|
||||||
|
Assert.Equal("Str_KS_Open", map[KbLayer.Ctrl]["O"].Label); // inherited
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The three bindings 1.7.5 changed, pinned so a later edit cannot quietly undo
|
||||||
|
/// them: the sidebar is F9 and Shift+F9, and Ctrl+B is bold rather than the sidebar.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void TheSidebarIsOnF9AndCtrlBIsBold()
|
||||||
|
{
|
||||||
|
var map = ShortcutTable.BuildMap();
|
||||||
|
|
||||||
|
Assert.Equal("Str_KS_ToggleSidebar", map[KbLayer.Base]["F9"].Label);
|
||||||
|
Assert.Equal("Str_KS_SidebarSide", map[KbLayer.Shift]["F9"].Label);
|
||||||
|
Assert.Equal("Str_Lbl_Bold", map[KbLayer.Ctrl]["B"].Label);
|
||||||
|
Assert.False(map[KbLayer.Ctrl].ContainsKey("B") &&
|
||||||
|
map[KbLayer.Ctrl]["B"].Label == "Str_KS_ToggleSidebar",
|
||||||
|
"Ctrl+B must not be the sidebar again");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Alt+M was in the list and missing from the map for as long as it existed.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void AltMIsOnTheMap()
|
||||||
|
{
|
||||||
|
Assert.Equal("Str_Toolbar_Hide", ShortcutTable.BuildMap()[KbLayer.Alt]["M"].Label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gesture-only rows (the wheel ones, Ctrl+Scroll, Shift+Click, Middle drag) have
|
||||||
|
/// no keycap by design. Anything else with no caps is likely an oversight, so the count is
|
||||||
|
/// pinned rather than left to drift.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void OnlyMouseGesturesHaveNoCaps()
|
||||||
|
{
|
||||||
|
// Asserted by label key, not by the Keys text: the key column is tokenized, so pinning
|
||||||
|
// its literal spelling here would just break every time a token is renamed.
|
||||||
|
var capless = ShortcutTable.KsAll.Where(b => b.Caps.Length == 0)
|
||||||
|
.Select(b => b.LabelKey).ToList();
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { "Str_KS_ZoomCursor", "Str_KS_PanView", "Str_KS_CycleView",
|
||||||
|
"Str_KS_AppSize", "Str_KS_MultiSelect" }
|
||||||
|
.OrderBy(s => s, StringComparer.Ordinal).ToArray(),
|
||||||
|
capless.OrderBy(s => s, StringComparer.Ordinal).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every token in the key column must be one the renderer knows, or it prints raw to the
|
||||||
|
/// user as "%shft%". The zoom pair is substituted from the keyboard layout rather than the
|
||||||
|
/// locale (Services/KeyLayout.cs), so it is known-good without a resource key.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void EveryKeyTokenIsResolvable()
|
||||||
|
{
|
||||||
|
var known = ShortcutTable.KeyTokens.Select(t => t.Token)
|
||||||
|
.Concat(new[] { "%zin%", "%zout%" })
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
var unknown = ShortcutTable.KsAll
|
||||||
|
.SelectMany(b => Regex.Matches(b.Keys, "%[a-z]+%").Cast<Match>().Select(m => m.Value))
|
||||||
|
.Distinct()
|
||||||
|
.Where(t => !known.Contains(t))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.True(unknown.Count == 0,
|
||||||
|
"these tokens have no resource key and would render raw: " + string.Join(", ", unknown));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EveryKeyTokenResourceExistsInEnglish()
|
||||||
|
{
|
||||||
|
var english = EnglishKeys();
|
||||||
|
var missing = ShortcutTable.KeyTokens.Select(t => t.Key)
|
||||||
|
.Where(k => !english.Contains(k)).ToList();
|
||||||
|
|
||||||
|
Assert.True(missing.Count == 0,
|
||||||
|
"key-name resources missing from en-US.xaml: " + string.Join(", ", missing));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The point of #230: no bare English key name is left sitting in the table. Only
|
||||||
|
/// letters, digits, F-numbers, arrows and the chord punctuation may appear literally.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void NoUntranslatedKeyNamesRemainInTheTable()
|
||||||
|
{
|
||||||
|
var offenders = ShortcutTable.KsAll
|
||||||
|
.Where(b => Regex.IsMatch(Regex.Replace(b.Keys, "%[a-z]+%", ""),
|
||||||
|
@"\b(Ctrl|Alt|Shift|Delete|Enter|Escape|Esc|Menu|Home|End|PgUp|PgDn|Tab|Scroll|Click|Wheel|Space|Middle|drag|or)\b"))
|
||||||
|
.Select(b => b.Keys)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.True(offenders.Count == 0,
|
||||||
|
"these rows still carry hardcoded English key names: " + string.Join(" | ", offenders));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using KillerPDF.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KillerPDF.Tests
|
||||||
|
{
|
||||||
|
public class SignatureStoreTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void NewStore_HasEmptySignatures()
|
||||||
|
{
|
||||||
|
var store = new SignatureStore();
|
||||||
|
Assert.Empty(store.Signatures);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Add_IncreasesCount()
|
||||||
|
{
|
||||||
|
var store = new SignatureStore();
|
||||||
|
store.Add(new SavedSignature { Name = "Test" });
|
||||||
|
Assert.Single(store.Signatures);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Remove_DecreasesCount()
|
||||||
|
{
|
||||||
|
var store = new SignatureStore();
|
||||||
|
var sig = new SavedSignature { Name = "Test" };
|
||||||
|
store.Add(sig);
|
||||||
|
store.Remove(sig);
|
||||||
|
Assert.Empty(store.Signatures);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_PersistAndLoad()
|
||||||
|
{
|
||||||
|
var dir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||||
|
System.IO.Directory.CreateDirectory(dir);
|
||||||
|
var file = System.IO.Path.Combine(dir, "sig_test.json");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var store1 = new SignatureStore(dir, file);
|
||||||
|
store1.Add(new SavedSignature { Name = "Alpha", CanvasWidth = 400, CanvasHeight = 150 });
|
||||||
|
store1.Add(new SavedSignature { Name = "Beta", CanvasWidth = 300, CanvasHeight = 100 });
|
||||||
|
store1.Persist();
|
||||||
|
|
||||||
|
var store2 = new SignatureStore(dir, file);
|
||||||
|
store2.Load();
|
||||||
|
|
||||||
|
Assert.Equal(2, store2.Signatures.Count);
|
||||||
|
Assert.Equal("Alpha", store2.Signatures[0].Name);
|
||||||
|
Assert.Equal("Beta", store2.Signatures[1].Name);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
System.IO.Directory.Delete(dir, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using PdfSharpCore.Drawing;
|
||||||
|
using PdfSharpCore.Drawing.Layout;
|
||||||
|
using PdfSharpCore.Pdf;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KillerPDF.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Regression cover for burning multi-line text annotations into a document (#142).
|
||||||
|
/// CreateBlocks emits a Block(BlockType.LineBreak) per newline, and that constructor never sets
|
||||||
|
/// Text. CreateLayout then skips assigning those blocks a Location, so they keep XPoint(0,0) and
|
||||||
|
/// GetLines' GroupBy lumps them in with the first line. The Justify branch of DrawString called
|
||||||
|
/// block.Text.Trim() on them and threw NullReferenceException.
|
||||||
|
/// Only the Justify path was affected - the other alignments join blocks with string.Join, which
|
||||||
|
/// tolerates nulls. The short DrawString overload defaults to Justify, so KillerPDF hit it.
|
||||||
|
/// </summary>
|
||||||
|
public class TextFormatterTests
|
||||||
|
{
|
||||||
|
static void Draw(string content, TextFormatAlignment? alignment = null)
|
||||||
|
{
|
||||||
|
var doc = new PdfDocument();
|
||||||
|
var page = doc.AddPage();
|
||||||
|
var gfx = XGraphics.FromPdfPage(page);
|
||||||
|
var font = new XFont("Segoe UI", 12, XFontStyle.Regular);
|
||||||
|
var rect = new XRect(20, 20, 300, 200);
|
||||||
|
var tf = new XTextFormatter(gfx);
|
||||||
|
|
||||||
|
if (alignment == null)
|
||||||
|
tf.DrawString(content, font, XBrushes.Black, rect);
|
||||||
|
else
|
||||||
|
tf.DrawString(content, font, XBrushes.Black, rect, alignment);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DrawString_SingleLine_DoesNotThrow()
|
||||||
|
{
|
||||||
|
Draw("Hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DrawString_MultiLine_DoesNotThrow()
|
||||||
|
{
|
||||||
|
// The short overload defaults to Justify - this is the exact call KillerPDF makes when
|
||||||
|
// burning a text annotation, and the one that crashed.
|
||||||
|
Draw("Hello\nworld");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DrawString_MultiLineCrLf_DoesNotThrow()
|
||||||
|
{
|
||||||
|
Draw("Hello\r\nworld");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DrawString_MultiLineLeftAligned_DoesNotThrow()
|
||||||
|
{
|
||||||
|
// What the annotation burn now passes explicitly, matching the on-screen TextBlock.
|
||||||
|
Draw("Hello\nworld", new TextFormatAlignment { Horizontal = XParagraphAlignment.Left });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DrawString_ConsecutiveNewlines_DoesNotThrow()
|
||||||
|
{
|
||||||
|
// A blank line produces two adjacent LineBreak blocks, so the line group is entirely
|
||||||
|
// non-drawable. Guards the empty-after-filter path.
|
||||||
|
Draw("Hello\n\n\nworld");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using KillerPDF.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KillerPDF.Tests;
|
||||||
|
|
||||||
|
public sealed class TextRunServiceTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("English text", false)]
|
||||||
|
[InlineData("متن فارسی", true)]
|
||||||
|
[InlineData("نص عربي", true)]
|
||||||
|
[InlineData("טקסט עברי", true)]
|
||||||
|
[InlineData("1234", false)]
|
||||||
|
public void DetectsLineDirection(string text, bool expected)
|
||||||
|
=> Assert.Equal(expected, TextRunService.IsRightToLeftText(new[] { text }));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RightToLeftCaretMovesFromRightEdgeToLeftEdge()
|
||||||
|
{
|
||||||
|
var runs = new PageTextRuns();
|
||||||
|
runs.Chars.Add(new RunChar("א", 90, 100, 0, 0));
|
||||||
|
runs.Chars.Add(new RunChar("ב", 80, 90, 0, 0));
|
||||||
|
runs.Chars.Add(new RunChar("ג", 70, 80, 0, 0));
|
||||||
|
runs.Lines.Add(new RunLine
|
||||||
|
{
|
||||||
|
Start = 0,
|
||||||
|
Count = 3,
|
||||||
|
Top = 20,
|
||||||
|
Bottom = 10,
|
||||||
|
Left = 70,
|
||||||
|
Right = 100,
|
||||||
|
RightToLeft = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(0, TextRunService.CaretFromPoint(runs, 101, 15));
|
||||||
|
Assert.Equal(1, TextRunService.CaretFromPoint(runs, 89, 15));
|
||||||
|
Assert.Equal(3, TextRunService.CaretFromPoint(runs, 69, 15));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using KillerPDF.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace KillerPDF.Tests;
|
||||||
|
|
||||||
|
public sealed class WheelPageFlipGateTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime Start = new(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TwoQuickNotchesAtEdge_ConfirmPageFlip()
|
||||||
|
{
|
||||||
|
var gate = new WheelPageFlipGate();
|
||||||
|
|
||||||
|
Assert.False(gate.TryConfirm(-120, Start));
|
||||||
|
Assert.True(gate.TryConfirm(-120, Start.AddMilliseconds(100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MomentumImmediatelyAfterContentScroll_DoesNotFlip()
|
||||||
|
{
|
||||||
|
var gate = new WheelPageFlipGate();
|
||||||
|
gate.NoteContentScroll(Start);
|
||||||
|
|
||||||
|
Assert.False(gate.TryConfirm(-120, Start.AddMilliseconds(50)));
|
||||||
|
Assert.False(gate.TryConfirm(-120, Start.AddMilliseconds(150)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OppositeDirection_RestartsConfirmation()
|
||||||
|
{
|
||||||
|
var gate = new WheelPageFlipGate();
|
||||||
|
|
||||||
|
Assert.False(gate.TryConfirm(-120, Start));
|
||||||
|
Assert.False(gate.TryConfirm(120, Start.AddMilliseconds(100)));
|
||||||
|
Assert.True(gate.TryConfirm(120, Start.AddMilliseconds(200)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SlowNotches_DoNotCombineIntoPageFlip()
|
||||||
|
{
|
||||||
|
var gate = new WheelPageFlipGate();
|
||||||
|
|
||||||
|
Assert.False(gate.TryConfirm(-120, Start));
|
||||||
|
Assert.False(gate.TryConfirm(-120, Start.AddMilliseconds(700)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net48</TargetFramework>
|
||||||
|
<PlatformTarget>x64</PlatformTarget>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<!-- PolySharp 1.16.0 generates Microsoft.CodeAnalysis.EmbeddedAttribute by default; the
|
||||||
|
compiler reserves that name (CS8336). Opt back out to keep net48 building. -->
|
||||||
|
<PolySharpUseEmbeddedAttributeForGeneratedTypes>false</PolySharpUseEmbeddedAttributeForGeneratedTypes>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UseWPF>true</UseWPF>
|
||||||
|
<ApplicationIcon>Resources\kp-icon.ico</ApplicationIcon>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<Version>1.7.5</Version>
|
||||||
|
<AssemblyVersion>1.7.5.0</AssemblyVersion>
|
||||||
|
<FileVersion>1.7.5.0</FileVersion>
|
||||||
|
<!-- Shown in the About card beside the version so a user can tell how old their build
|
||||||
|
is. Must match the date on this version's CHANGELOG section - release.ps1 preflight
|
||||||
|
fails the release if it does not. Bump it with the version. -->
|
||||||
|
<ReleaseDate>2026-08-22</ReleaseDate>
|
||||||
|
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- The release pipeline builds the installed/inner app as ordinary loose files. Keep this
|
||||||
|
opt-in so normal IDE builds remain convenient single-exe development builds while releases
|
||||||
|
use the faster installed payload. A separate intermediate directory prevents Fody's copy-local cache from the
|
||||||
|
woven build from hiding the payload dependencies when weaving is disabled. -->
|
||||||
|
<PropertyGroup Condition="'$(KillerPayloadBuild)' == 'true'">
|
||||||
|
<AssemblyName>KillerPDF.App</AssemblyName>
|
||||||
|
<DisableFody>true</DisableFody>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Fody's copy-local update targets still execute when weaving is disabled. Remove caches from a
|
||||||
|
preceding woven build so those targets cannot reuse a list that deliberately removed every
|
||||||
|
dependency from the output. -->
|
||||||
|
<Target Name="ClearWovenCopyLocalCacheForPayload" BeforeTargets="ResolveReferences"
|
||||||
|
Condition="'$(KillerPayloadBuild)' == 'true'">
|
||||||
|
<Delete Files="$(IntermediateOutputPath)$(MSBuildProjectFile).Fody.CopyLocal.cache;$(IntermediateOutputPath)$(MSBuildProjectFile).Fody.RuntimeCopyLocal.cache" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<!-- Bake ReleaseDate into the assembly so About can show it. A file timestamp would not
|
||||||
|
survive being copied, and the PE linker stamp is a build date, not a release date. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
|
||||||
|
<_Parameter1>ReleaseDate</_Parameter1>
|
||||||
|
<_Parameter2>$(ReleaseDate)</_Parameter2>
|
||||||
|
</AssemblyAttribute>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- The vendored PdfSharpCore builds as its own ProjectReference; keep the app's implicit
|
||||||
|
source globs OUT of third_party or every vendored file compiles twice. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Remove="third_party\**" />
|
||||||
|
<Compile Remove="Packaging\**" />
|
||||||
|
<EmbeddedResource Remove="third_party\**" />
|
||||||
|
<EmbeddedResource Remove="Packaging\**" />
|
||||||
|
<None Remove="third_party\**" />
|
||||||
|
<None Remove="Packaging\**" />
|
||||||
|
<Content Remove="third_party\**" />
|
||||||
|
<Content Remove="Packaging\**" />
|
||||||
|
<Page Remove="third_party\**" />
|
||||||
|
<Page Remove="Packaging\**" />
|
||||||
|
<Resource Remove="third_party\**" />
|
||||||
|
<Resource Remove="Packaging\**" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Costura.Fody" Version="6.2.0">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Fody" Version="6.9.3">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<!-- PdfSharpCore is vendored (third_party/PdfSharpCore, MIT) so PDF/A conformance bugs can be patched at the source - see VENDORED.txt -->
|
||||||
|
<ProjectReference Include="third_party\PdfSharpCore\PdfSharpCore.csproj" />
|
||||||
|
<!-- Digital signatures only (Services/Signing). Separate namespace (PdfSharp.*) so it does not
|
||||||
|
clash with PdfSharpCore, which still drives the rest of the app. -->
|
||||||
|
<PackageReference Include="PDFsharp" Version="6.2.4" />
|
||||||
|
<PackageReference Include="PdfPig" Version="0.1.15" />
|
||||||
|
<PackageReference Include="Docnet.Core" Version="2.6.0" />
|
||||||
|
<!-- We embed the x64 natives as resources and self-extract them at runtime (OcrNativeBootstrap), so the
|
||||||
|
package's loose x86/x64 native copies in the output are dead weight. ExcludeAssets stops that copy
|
||||||
|
while keeping the managed assembly (compile/runtime) and the GeneratePathProperty path for the embed. -->
|
||||||
|
<PackageReference Include="Tesseract" Version="5.2.0" GeneratePathProperty="true" ExcludeAssets="build;buildTransitive;native;contentFiles" />
|
||||||
|
<PackageReference Include="System.Text.Json" Version="10.0.11" />
|
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
|
<!-- Transitive pins only (family standard, same as Killendar/KillerNotes): Costura's graph
|
||||||
|
otherwise resolves 4.3.0 of both, which carry advisories (GHSA-7jgj-8wvc-jh57,
|
||||||
|
GHSA-cmhx-cq75-c4mj). ExcludeAssets="all" means nothing ships - the pin just forces the
|
||||||
|
resolver past the vulnerable versions. net48 runs the framework's own copies anyway. -->
|
||||||
|
<PackageReference Include="System.Net.Http" Version="4.3.4" ExcludeAssets="all" />
|
||||||
|
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" ExcludeAssets="all" />
|
||||||
|
<PackageReference Include="PolySharp" Version="1.16.0">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="Microsoft.CSharp" />
|
||||||
|
<Reference Include="System.Printing" />
|
||||||
|
<Reference Include="ReachFramework" />
|
||||||
|
<Reference Include="System.Net.Http" />
|
||||||
|
<Reference Include="System.IO.Compression" />
|
||||||
|
<Reference Include="System.IO.Compression.FileSystem" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- KillerPDF.Tests is its own project. Keep its entire tree out of this project's SDK default
|
||||||
|
globs, or its bin\ DLLs become items here (MSB3277 System.ValueTuple conflict) and the folder
|
||||||
|
shows inside this project in Solution Explorer. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Remove="KillerPDF.Tests\**" />
|
||||||
|
<None Remove="KillerPDF.Tests\**" />
|
||||||
|
<Content Remove="KillerPDF.Tests\**" />
|
||||||
|
<Resource Remove="KillerPDF.Tests\**" />
|
||||||
|
<Page Remove="KillerPDF.Tests\**" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- /brand holds heavy source art only (stored locally, see .gitignore). Keep it out of the SDK default
|
||||||
|
globs so it is not part of the project. This targets KillerPDF\brand only, not pdf-landing\brand. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<None Remove="brand\**" />
|
||||||
|
<Content Remove="brand\**" />
|
||||||
|
<Resource Remove="brand\**" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Resource Include="Resources\kp-icon.ico" />
|
||||||
|
<Resource Include="Resources\kp-icon.png" />
|
||||||
|
<Resource Include="Fonts\Typewriter-A602.ttf" />
|
||||||
|
<EmbeddedResource Include="Resources\pdf-file.ico" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- OCR: embed only the native Tesseract libs (x64) so the app still ships as a single exe. Language data
|
||||||
|
is NOT bundled - it is downloaded on demand on first OCR (see EnsureOcrModelsReadyAsync), which keeps
|
||||||
|
the exe small. OcrNativeBootstrap extracts the natives to a per-version cache under %LOCALAPPDATA% on
|
||||||
|
first use, the same self-extract pattern Costura uses for the managed assemblies. PkgTesseract comes
|
||||||
|
from GeneratePathProperty. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="$(PkgTesseract)\x64\tesseract50.dll" Condition="'$(PkgTesseract)' != ''">
|
||||||
|
<LogicalName>KillerPDF.OcrNative.tesseract50.dll</LogicalName>
|
||||||
|
</EmbeddedResource>
|
||||||
|
<EmbeddedResource Include="$(PkgTesseract)\x64\leptonica-1.82.0.dll" Condition="'$(PkgTesseract)' != ''">
|
||||||
|
<LogicalName>KillerPDF.OcrNative.leptonica-1.82.0.dll</LogicalName>
|
||||||
|
</EmbeddedResource>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- GPL3 source bundle: produces <AppName>-<Version>-src.zip in the publish folder every time you Publish. -->
|
||||||
|
<Target Name="BundleSource" AfterTargets="Publish" Condition="'$(KillerPayloadBuild)' != 'true'">
|
||||||
|
<Exec Command="powershell -NoProfile -ExecutionPolicy Bypass -File "$(ProjectDir)build\bundle-source.ps1" -ProjectDir "$(ProjectDir.TrimEnd('\'))" -Version "$(Version)" -AppName "$(AssemblyName)" -PublishDir "$(PublishDir.TrimEnd('\'))"" IgnoreExitCode="true" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Binary file not shown.
@@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.14.37027.9 d17.14
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KillerPDF", "KillerPDF.csproj", "{D7AFAEB6-1B7F-41FB-86C5-A5B17169D42F}"
|
||||||
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PdfSharpCore", "third_party\PdfSharpCore\PdfSharpCore.csproj", "{4C8A9D31-52E6-4B7A-9F0D-2A6E8B1C7D45}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{D7AFAEB6-1B7F-41FB-86C5-A5B17169D42F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{D7AFAEB6-1B7F-41FB-86C5-A5B17169D42F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{D7AFAEB6-1B7F-41FB-86C5-A5B17169D42F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{D7AFAEB6-1B7F-41FB-86C5-A5B17169D42F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{4C8A9D31-52E6-4B7A-9F0D-2A6E8B1C7D45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{4C8A9D31-52E6-4B7A-9F0D-2A6E8B1C7D45}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{4C8A9D31-52E6-4B7A-9F0D-2A6E8B1C7D45}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{4C8A9D31-52E6-4B7A-9F0D-2A6E8B1C7D45}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {45DCC74D-BB06-4D90-8EEE-8FDE7B8AB84E}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
+2837
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,861 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Animation;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using System.Windows.Shapes;
|
||||||
|
using Docnet.Core;
|
||||||
|
using Docnet.Core.Models;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using PdfSharpCore.Drawing;
|
||||||
|
using PdfSharpCore.Pdf;
|
||||||
|
using PdfSharpCore.Pdf.IO;
|
||||||
|
using KillerPDF.Services;
|
||||||
|
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
|
||||||
|
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
public partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
private PdfDocument? _doc { get => ActiveViewer?.DocumentRef; set { if (ActiveViewer != null) ActiveViewer.DocumentRef = value; } }
|
||||||
|
private string? _currentFile { get => ActiveViewer?.CurrentFileRef; set { if (ActiveViewer != null) ActiveViewer.CurrentFileRef = value; } }
|
||||||
|
private string? _originalFile { get => ActiveViewer?.OriginalFileRef; set { if (ActiveViewer != null) ActiveViewer.OriginalFileRef = value; } }
|
||||||
|
private Point _dragStartPoint;
|
||||||
|
|
||||||
|
// Zoom
|
||||||
|
private double _zoomLevel { get => _view.ZoomLevel; set => _view.ZoomLevel = value; }
|
||||||
|
private double _lastRenderZoom { get => _view.LastRenderZoom; set => _view.LastRenderZoom = value; }
|
||||||
|
private int _renderedPrimaryPage { get => _view.RenderedPrimaryPage; set => _view.RenderedPrimaryPage = value; }
|
||||||
|
// internal: the viewer control aliases these as its own consts (PdfViewer.Bridge.cs).
|
||||||
|
// They stay declared here because MainWindow.xaml.cs and KeyboardShortcuts.cs read them
|
||||||
|
// too, and a const costs nothing to alias but would drift if duplicated.
|
||||||
|
internal const double ZoomMin = 0.05;
|
||||||
|
internal const double ZoomMax = 5.0;
|
||||||
|
internal const double ZoomStep = 0.15;
|
||||||
|
private FitMode _fitMode { get => _view.Fit; set => _view.Fit = value; }
|
||||||
|
private System.Windows.Threading.DispatcherTimer? _rerenderTimer { get => _view.RerenderTimer; set => _view.RerenderTimer = value; }
|
||||||
|
private System.Threading.CancellationTokenSource? _secondaryRenderCts { get => _view.SecondaryRenderCts; set => _view.SecondaryRenderCts = value; }
|
||||||
|
// ── Split pane ─────────────────────────────────────────────────────────────────────
|
||||||
|
// The per-view state lives in one object (Models/ViewerState.cs) so a second pane can have
|
||||||
|
// its own, and the VIEWER owns it, not the window. The window reads it back through here,
|
||||||
|
// so the forwarding properties below (and the ~500 call sites behind them) stay as they
|
||||||
|
// are. See BACKLOG.md "Split pane (F10)".
|
||||||
|
// ActiveViewer, NOT Viewer: with two panes this has to be the FOCUSED pane's state, or every
|
||||||
|
// forwarding property behind it (view mode, zoom, page maps - ~500 call sites) would keep
|
||||||
|
// reporting pane A no matter which pane the user is working in.
|
||||||
|
private ViewerState _view => ActiveViewer.State;
|
||||||
|
|
||||||
|
private ViewMode _viewMode { get => _view.Mode; set => _view.Mode = value; }
|
||||||
|
private StackPanel _continuousPanel { get => _view.ContinuousPanel; set => _view.ContinuousPanel = value; }
|
||||||
|
private System.Threading.CancellationTokenSource? _continuousRenderCts { get => _view.ContinuousRenderCts; set => _view.ContinuousRenderCts = value; }
|
||||||
|
private System.Threading.CancellationTokenSource? _continuousSharpenCts { get => _view.ContinuousSharpenCts; set => _view.ContinuousSharpenCts = value; }
|
||||||
|
private HashSet<int> _continuousSharpPages => _view.ContinuousSharpPages;
|
||||||
|
private int _continuousSharpW { get => _view.ContinuousSharpW; set => _view.ContinuousSharpW = value; }
|
||||||
|
private List<double> _continuousTops => _view.ContinuousTops;
|
||||||
|
private int _gridScrollToPage { get => _view.GridScrollToPage; set => _view.GridScrollToPage = value; }
|
||||||
|
private int _continuousScrollTarget { get => _view.ContinuousScrollTarget; set => _view.ContinuousScrollTarget = value; }
|
||||||
|
private double _continuousPageW { get => _view.ContinuousPageW; set => _view.ContinuousPageW = value; }
|
||||||
|
|
||||||
|
// Editing
|
||||||
|
private EditTool _currentTool
|
||||||
|
{
|
||||||
|
get => ActiveViewer?.CurrentToolRef ?? EditTool.Select;
|
||||||
|
set { if (ActiveViewer != null) ActiveViewer.CurrentToolRef = value; }
|
||||||
|
}
|
||||||
|
// Per-document state. Not readonly: tab switching swaps these by reference so each
|
||||||
|
// open document keeps its own annotations, undo history, form values, and search hits.
|
||||||
|
private Dictionary<int, List<PageAnnotation>> _annotations { get => ActiveViewer.AnnotationsRef; set => ActiveViewer.AnnotationsRef = value; }
|
||||||
|
private Dictionary<int, (int w, int h)> _renderDims { get => ActiveViewer.RenderDimsRef; set => ActiveViewer.RenderDimsRef = value; }
|
||||||
|
// Stores the PDF /Rotate value for each page. The temp file used by Docnet has
|
||||||
|
// rotation stripped to zero so FPDF_GetPageWidth/Height returns MediaBox dims and
|
||||||
|
// the content isn't clipped; RotateBitmap is applied at render time instead.
|
||||||
|
private Dictionary<int, int> _pageRotations { get => ActiveViewer.PageRotationsRef; set => ActiveViewer.PageRotationsRef = value; }
|
||||||
|
|
||||||
|
// Form filling - text/check keyed by widget object number; radio keyed by field name
|
||||||
|
private Dictionary<int, string> _formTextValues { get => ActiveViewer.FormTextValuesRef; set => ActiveViewer.FormTextValuesRef = value; }
|
||||||
|
private Dictionary<int, bool> _formCheckValues { get => ActiveViewer.FormCheckValuesRef; set => ActiveViewer.FormCheckValuesRef = value; }
|
||||||
|
private Dictionary<string, string> _formRadioValues { get => ActiveViewer.FormRadioValuesRef; set => ActiveViewer.FormRadioValuesRef = value; }
|
||||||
|
private Dictionary<int, double> _formFontSizes { get => ActiveViewer.FormFontSizesRef; set => ActiveViewer.FormFontSizesRef = value; }
|
||||||
|
// Floating font-size stepper shown while a form text field is focused.
|
||||||
|
private Border? _formSizeBar { get => ActiveViewer.FormSizeBarRef; set => ActiveViewer.FormSizeBarRef = value; }
|
||||||
|
private TextBox? _activeFormTb { get => ActiveViewer.ActiveFormTbRef; set => ActiveViewer.ActiveFormTbRef = value; }
|
||||||
|
private int _activeFormObj { get => ActiveViewer.ActiveFormObjRef; set => ActiveViewer.ActiveFormObjRef = value; }
|
||||||
|
private double _activeFormScale { get => ActiveViewer.ActiveFormScaleRef; set => ActiveViewer.ActiveFormScaleRef = value; }
|
||||||
|
private const string FormOverlayTag = "FormFieldOverlay";
|
||||||
|
|
||||||
|
// Undo stack - each entry is either an annotation removal or a full document snapshot.
|
||||||
|
// AnnotationGroup removes a specific set of annotations in one step (a text edit = cover + text).
|
||||||
|
// UndoKind / UndoEntry live in Models/UndoTypes.cs, not here - the undo stack is pushed from
|
||||||
|
// Annotations.cs and TextEditing.cs, which live in the viewer control.
|
||||||
|
private Stack<UndoEntry> _undoStack { get => ActiveViewer.UndoStackRef; set => ActiveViewer.UndoStackRef = value; }
|
||||||
|
// Redo: inverses captured by Undo_Click land here; any NEW edit clears it (PushUndo).
|
||||||
|
// Swapped per tab alongside _undoStack (Tabs.cs) so redo can never replay another document.
|
||||||
|
private Stack<UndoEntry> _redoStack { get => ActiveViewer.RedoStackRef; set => ActiveViewer.RedoStackRef = value; }
|
||||||
|
// Jump history for Alt+Left / Alt+Right and the mouse back/forward buttons. Page-granular,
|
||||||
|
// recorded at the long-jump sites (bookmark, internal link, jump box, Home/End); cleared on
|
||||||
|
// document open and tab switch.
|
||||||
|
private Stack<int> _navBack => ActiveViewer.NavBackRef;
|
||||||
|
private Stack<int> _navForward => ActiveViewer.NavForwardRef;
|
||||||
|
private bool _isDrawing { get => ActiveViewer.IsDrawingRef; set => ActiveViewer.IsDrawingRef = value; }
|
||||||
|
private Point _drawStart { get => ActiveViewer.DrawStartRef; set => ActiveViewer.DrawStartRef = value; }
|
||||||
|
private UIElement? _activePreview { get => ActiveViewer.ActivePreviewRef; set => ActiveViewer.ActivePreviewRef = value; }
|
||||||
|
private InkAnnotation? _activeInk { get => ActiveViewer.ActiveInkRef; set => ActiveViewer.ActiveInkRef = value; }
|
||||||
|
private TextBox? _activeTextBox { get => ActiveViewer.ActiveTextBoxRef; set => ActiveViewer.ActiveTextBoxRef = value; }
|
||||||
|
private PageAnnotation? _selectedAnnotation { get => ActiveViewer.SelectedAnnotationRef; set => ActiveViewer.SelectedAnnotationRef = value; }
|
||||||
|
private Border? _selectionBorder { get => ActiveViewer.SelectionBorderRef; set => ActiveViewer.SelectionBorderRef = value; }
|
||||||
|
// Shift+click multi-selection (Select tool): extra annotations selected alongside the
|
||||||
|
// primary _selectedAnnotation. Each gets its own outline. Delete removes the whole set.
|
||||||
|
private List<PageAnnotation> _selectedSet => ActiveViewer.SelectedSetRef;
|
||||||
|
private List<Border> _selectionOutlines => ActiveViewer.SelectionOutlinesRef;
|
||||||
|
|
||||||
|
// Draw/Highlight settings
|
||||||
|
private Color _drawColor { get => ActiveViewer.DrawColorRef; set => ActiveViewer.DrawColorRef = value; }
|
||||||
|
private double _drawWidth { get => ActiveViewer.DrawWidthRef; set => ActiveViewer.DrawWidthRef = value; }
|
||||||
|
private byte _drawOpacity { get => ActiveViewer.DrawOpacityRef; set => ActiveViewer.DrawOpacityRef = value; }
|
||||||
|
private bool _lineLevel { get => ActiveViewer.LineLevelRef; set => ActiveViewer.LineLevelRef = value; }
|
||||||
|
private bool _highlightErase { get => ActiveViewer.HighlightEraseRef; set => ActiveViewer.HighlightEraseRef = value; }
|
||||||
|
private bool _drawErase { get => ActiveViewer.DrawEraseRef; set => ActiveViewer.DrawEraseRef = value; }
|
||||||
|
private Color _highlightColor { get => ActiveViewer.HighlightColorRef; set => ActiveViewer.HighlightColorRef = value; }
|
||||||
|
// Strikethrough / underline lines: opaque red by default.
|
||||||
|
private Color _lineAnnotColor { get => ActiveViewer.LineAnnotColorRef; set => ActiveViewer.LineAnnotColorRef = value; }
|
||||||
|
private Border? _drawSettingsBar;
|
||||||
|
|
||||||
|
// Text (typewriter) tool settings
|
||||||
|
private double _textFontSize { get => ActiveViewer.TextFontSizeRef; set => ActiveViewer.TextFontSizeRef = value; }
|
||||||
|
// Current text-tool typeface and style (mirrors the text bar; carried onto each new/edited box).
|
||||||
|
private string _textFontName { get => ActiveViewer.TextFontNameRef; set => ActiveViewer.TextFontNameRef = value; }
|
||||||
|
private bool _textBold { get => ActiveViewer.TextBoldRef; set => ActiveViewer.TextBoldRef = value; }
|
||||||
|
private bool _textItalic { get => ActiveViewer.TextItalicRef; set => ActiveViewer.TextItalicRef = value; }
|
||||||
|
private bool _textStrike { get => ActiveViewer.TextStrikeRef; set => ActiveViewer.TextStrikeRef = value; }
|
||||||
|
private bool _textUnderline { get => ActiveViewer.TextUnderlineRef; set => ActiveViewer.TextUnderlineRef = value; }
|
||||||
|
// Installed font-family names, sorted, computed once (the text bar rebuilds often).
|
||||||
|
private static List<string>? _systemFontNamesCache;
|
||||||
|
internal static List<string> SystemFontNames => _systemFontNamesCache ??=
|
||||||
|
[.. System.Windows.Media.Fonts.SystemFontFamilies
|
||||||
|
.Select(f => f.Source).Where(s => !string.IsNullOrWhiteSpace(s))
|
||||||
|
.Distinct().OrderBy(s => s, StringComparer.OrdinalIgnoreCase)];
|
||||||
|
// WPF renders a given point size visually ~25% larger than the source PDF text, so scale the
|
||||||
|
// detected size down when seeding an existing-text edit. The user can still fine-tune after.
|
||||||
|
private const double EditTextSizeCorrection = 0.8;
|
||||||
|
private bool _suppressSizeSync; // guards the slider<->size-box two-way binding from feedback loops
|
||||||
|
private TextAnnotation? _reeditOriginal { get => ActiveViewer.ReeditOriginalRef; set => ActiveViewer.ReeditOriginalRef = value; }
|
||||||
|
// The opaque cover dropped when starting an existing-text edit, awaiting its paired text commit.
|
||||||
|
// Held so the text commit can group both into one undo, and so cancel/empty removes the cover.
|
||||||
|
private CoverAnnotation? _pendingCover { get => ActiveViewer.PendingCoverRef; set => ActiveViewer.PendingCoverRef = value; }
|
||||||
|
// Dirty state captured before the cover was dropped, so undoing the grouped edit restores it.
|
||||||
|
private bool _pendingEditWasDirty { get => ActiveViewer.PendingEditWasDirtyRef; set => ActiveViewer.PendingEditWasDirtyRef = value; }
|
||||||
|
private Color _textColor { get => ActiveViewer.TextColorRef; set => ActiveViewer.TextColorRef = value; }
|
||||||
|
private byte _textOpacity { get => ActiveViewer.TextOpacityRef; set => ActiveViewer.TextOpacityRef = value; }
|
||||||
|
private Color _textFillColor { get => ActiveViewer.TextFillColorRef; set => ActiveViewer.TextFillColorRef = value; }
|
||||||
|
private const double TextBoxDefaultWidth = 220; // canvas-unit width of a freshly placed text box
|
||||||
|
private Border? _textSettingsBar { get => ActiveViewer.TextSettingsBarRef; set => ActiveViewer.TextSettingsBarRef = value; }
|
||||||
|
|
||||||
|
// Signature / image resize
|
||||||
|
private bool _isResizingSig { get => ActiveViewer.IsResizingSigRef; set => ActiveViewer.IsResizingSigRef = value; }
|
||||||
|
private Point _resizeSigStart { get => ActiveViewer.ResizeSigStartRef; set => ActiveViewer.ResizeSigStartRef = value; }
|
||||||
|
private double _resizeSigStartScale { get => ActiveViewer.ResizeSigStartScaleRef; set => ActiveViewer.ResizeSigStartScaleRef = value; }
|
||||||
|
private PlacedAnnotation? _resizeSigAnnot { get => ActiveViewer.ResizeSigAnnotRef; set => ActiveViewer.ResizeSigAnnotRef = value; }
|
||||||
|
private TextAnnotation? _resizeTextAnnot { get => ActiveViewer.ResizeTextAnnotRef; set => ActiveViewer.ResizeTextAnnotRef = value; }
|
||||||
|
private HighlightAnnotation? _resizeHlAnnot { get => ActiveViewer.ResizeHlAnnotRef; set => ActiveViewer.ResizeHlAnnotRef = value; }
|
||||||
|
private InkAnnotation? _resizeInkAnnot { get => ActiveViewer.ResizeInkAnnotRef; set => ActiveViewer.ResizeInkAnnotRef = value; }
|
||||||
|
private List<Point>? _resizeInkOrigPoints { get => ActiveViewer.ResizeInkOrigPointsRef; set => ActiveViewer.ResizeInkOrigPointsRef = value; }
|
||||||
|
private Rect _resizeInkOrigBounds { get => ActiveViewer.ResizeInkOrigBoundsRef; set => ActiveViewer.ResizeInkOrigBoundsRef = value; }
|
||||||
|
private List<Rectangle> _resizeHandles => ActiveViewer.ResizeHandlesRef;
|
||||||
|
private string _resizeCorner { get => ActiveViewer.ResizeCornerRef; set => ActiveViewer.ResizeCornerRef = value; }
|
||||||
|
private Point _resizeAnchor { get => ActiveViewer.ResizeAnchorRef; set => ActiveViewer.ResizeAnchorRef = value; }
|
||||||
|
|
||||||
|
// Mid-edit resize handles: 4 corners shown around the live editing TextBox so the user can
|
||||||
|
// resize the box (and continue typing) without committing and re-selecting first.
|
||||||
|
private List<Rectangle> _textEditHandles => ActiveViewer.TextEditHandlesRef;
|
||||||
|
private bool _draggingTextEditHandle { get => ActiveViewer.DraggingTextEditHandleRef; set => ActiveViewer.DraggingTextEditHandleRef = value; }
|
||||||
|
private string _tehCorner { get => ActiveViewer.TehCornerRef; set => ActiveViewer.TehCornerRef = value; }
|
||||||
|
private Point _tehAnchor { get => ActiveViewer.TehAnchorRef; set => ActiveViewer.TehAnchorRef = value; }
|
||||||
|
private TextBox? _tehBox { get => ActiveViewer.TehBoxRef; set => ActiveViewer.TehBoxRef = value; }
|
||||||
|
|
||||||
|
// Placed annotation drag-to-move
|
||||||
|
private bool _isDraggingAnnot { get => ActiveViewer.IsDraggingAnnotRef; set => ActiveViewer.IsDraggingAnnotRef = value; }
|
||||||
|
private Point _dragAnnotStart { get => ActiveViewer.DragAnnotStartRef; set => ActiveViewer.DragAnnotStartRef = value; }
|
||||||
|
|
||||||
|
// Middle-mouse / spacebar pan
|
||||||
|
private bool _spaceHeld;
|
||||||
|
private Point _dragAnnotOrigPos { get => ActiveViewer.DragAnnotOrigPosRef; set => ActiveViewer.DragAnnotOrigPosRef = value; }
|
||||||
|
private PageAnnotation? _dragAnnot { get => ActiveViewer.DragAnnotRef; set => ActiveViewer.DragAnnotRef = value; }
|
||||||
|
|
||||||
|
// Crop tool
|
||||||
|
private Rect _cropCanvasRect { get => ActiveViewer.CropCanvasRectRef; set => ActiveViewer.CropCanvasRectRef = value; }
|
||||||
|
private Rectangle? _cropPreviewRect { get => ActiveViewer.CropPreviewRectRef; set => ActiveViewer.CropPreviewRectRef = value; }
|
||||||
|
private Rectangle? _cropPreviewRectBorder { get => ActiveViewer.CropPreviewRectBorderRef; set => ActiveViewer.CropPreviewRectBorderRef = value; }
|
||||||
|
private List<System.Windows.Shapes.Path> _cropBrackets => ActiveViewer.CropBracketsRef;
|
||||||
|
private Border? _cropConfirmBar { get => ActiveViewer.CropConfirmBarRef; set => ActiveViewer.CropConfirmBarRef = value; }
|
||||||
|
private readonly Button _toolCropBtn = null!;
|
||||||
|
private readonly Button _toolRotateBtn = null!;
|
||||||
|
private List<Rectangle> _cropHandles => ActiveViewer.CropHandlesRef;
|
||||||
|
private string? _activeCropHandleTag { get => ActiveViewer.ActiveCropHandleTagRef; set => ActiveViewer.ActiveCropHandleTagRef = value; }
|
||||||
|
private Point _cropHandleDragStart { get => ActiveViewer.CropHandleDragStartRef; set => ActiveViewer.CropHandleDragStartRef = value; }
|
||||||
|
private Rect _cropRectAtHandleDrag { get => ActiveViewer.CropRectAtHandleDragRef; set => ActiveViewer.CropRectAtHandleDragRef = value; }
|
||||||
|
private TextBox? _cropXBox { get => ActiveViewer.CropXBoxRef; set => ActiveViewer.CropXBoxRef = value; }
|
||||||
|
private TextBox? _cropYBox { get => ActiveViewer.CropYBoxRef; set => ActiveViewer.CropYBoxRef = value; }
|
||||||
|
private TextBox? _cropWBox { get => ActiveViewer.CropWBoxRef; set => ActiveViewer.CropWBoxRef = value; }
|
||||||
|
private TextBox? _cropHBox { get => ActiveViewer.CropHBoxRef; set => ActiveViewer.CropHBoxRef = value; }
|
||||||
|
private TextBox? _cropRangeBox { get => ActiveViewer.CropRangeBoxRef; set => ActiveViewer.CropRangeBoxRef = value; }
|
||||||
|
private string _cropUnit { get => ActiveViewer.CropUnitRef; set => ActiveViewer.CropUnitRef = value; }
|
||||||
|
private bool _updatingCropInputs { get => ActiveViewer.UpdatingCropInputsRef; set => ActiveViewer.UpdatingCropInputsRef = value; }
|
||||||
|
|
||||||
|
// PDF link overlays (rendered on top of the annotation canvas)
|
||||||
|
|
||||||
|
// Sidebar + multi-page view
|
||||||
|
private bool _sidebarCollapsed;
|
||||||
|
private bool _sidebarRight; // false = sidebar on the left (default), true = on the right
|
||||||
|
private bool _sidebarShowingOutlines;
|
||||||
|
private bool _outlinesFitted = false;
|
||||||
|
private double _savedPagesWidth = 180;
|
||||||
|
private double _savedOutlinesWidth = 300;
|
||||||
|
private readonly Button _sidebarToggleBtn = null!;
|
||||||
|
private readonly Border _sidebarBorder = null!;
|
||||||
|
private ColumnDefinition _sidebarCol = null!; // sized column (left or right per _sidebarRight)
|
||||||
|
private WrapPanel _pageContentPanel { get => _view.PageContentPanel; set => _view.PageContentPanel = value; }
|
||||||
|
|
||||||
|
// Text selection
|
||||||
|
private Rectangle? _pairedCoverOutline { get => ActiveViewer.PairedCoverOutlineRef; set => ActiveViewer.PairedCoverOutlineRef = value; }
|
||||||
|
private Rectangle? _reeditCoverOutline { get => ActiveViewer.ReeditCoverOutlineRef; set => ActiveViewer.ReeditCoverOutlineRef = value; }
|
||||||
|
private string? _selectedText { get => ActiveViewer.SelectedTextRef; set => ActiveViewer.SelectedTextRef = value; }
|
||||||
|
|
||||||
|
// Search
|
||||||
|
private Border? _searchBar;
|
||||||
|
private TextBox? _searchBox;
|
||||||
|
private TextBlock? _searchStatus;
|
||||||
|
private readonly List<Rect> _searchHighlights = [];
|
||||||
|
|
||||||
|
// Signatures
|
||||||
|
private readonly SignatureStore _signatureStore = new();
|
||||||
|
private SavedSignature? _pendingSignature { get => ActiveViewer.PendingSignatureRef; set => ActiveViewer.PendingSignatureRef = value; }
|
||||||
|
private Border? _signaturePopup;
|
||||||
|
// Guided AcroForm signing: "pick once, reuse" - the chosen signature/initials are remembered
|
||||||
|
// and dropped into every matching field. _pendingSignField, when set, routes the next pick from
|
||||||
|
// the popup into that field instead of free placement.
|
||||||
|
private SavedSignature? _activeSignatureChoice;
|
||||||
|
private SavedSignature? _activeInitialsChoice;
|
||||||
|
private (bool Initials, int ObjNum, int Page, double X, double Y, double W, double H)? _pendingSignField;
|
||||||
|
// Form fields already signed, so re-clicking one offers change/remove instead of re-stamping.
|
||||||
|
private readonly Dictionary<int, SignatureAnnotation> _signedFields = [];
|
||||||
|
|
||||||
|
// Manual element refs. Tile-0's Image + overlay are built in code (BuildPrimaryTile) now that the
|
||||||
|
// primary page is no longer a hardcoded XAML singleton - both are reassignable.
|
||||||
|
private Canvas _annotationCanvas { get => _view.AnnotationCanvas; set => _view.AnnotationCanvas = value; }
|
||||||
|
private Image PageImage { get => _view.PageImage; set => _view.PageImage = value; }
|
||||||
|
// Active annotation surface. Single view: always _annotationCanvas. Continuous view:
|
||||||
|
// set on mouse-down to the clicked page's overlay. Shared handlers target this.
|
||||||
|
private Canvas _activeCanvas { get => _view.ActiveCanvas; set => _view.ActiveCanvas = value; }
|
||||||
|
// The page surface a pointer gesture started on, captured on mouse-down. Kept separate
|
||||||
|
// from _activeCanvas because RenderAllAnnotations reuses _activeCanvas as its render
|
||||||
|
// target; in Grid view tiles stream in asynchronously and each one re-points _activeCanvas
|
||||||
|
// mid-gesture, which previously committed annotations to the wrong page and broke
|
||||||
|
// select/delete. Mouse-move/up resolve the gesture page and surface from these instead.
|
||||||
|
private Canvas? _gestureCanvas { get => _view.GestureCanvas; set => _view.GestureCanvas = value; }
|
||||||
|
private int _gesturePage { get => _view.GesturePage; set => _view.GesturePage = value; }
|
||||||
|
// Per-page overlay canvases for Continuous view, keyed by page index.
|
||||||
|
private Dictionary<int, Canvas> _continuousCanvases => _view.ContinuousCanvases;
|
||||||
|
// Unified page -> overlay map covering EVERY rendered page, the primary included (unlike
|
||||||
|
// _continuousCanvases, which holds only secondary tiles and is driven by the tile-recycling
|
||||||
|
// machinery). This is the single source of truth the canvas accessors read from, so the
|
||||||
|
// primary stops being a special case in routing/search/links.
|
||||||
|
private Dictionary<int, Canvas> _pages => _view.Pages;
|
||||||
|
private Grid _pageContentGrid { get => _view.PageContentGrid; set => _view.PageContentGrid = value; }
|
||||||
|
// The page this view is showing. See ViewerState.CurrentPage for why this exists at all
|
||||||
|
// (reading the sidebar's SelectedIndex as the current page cannot survive a second pane).
|
||||||
|
// The setter drives the sidebar, which is what actually triggers navigation today via
|
||||||
|
// PageList_SelectionChanged; the handler mirrors the value
|
||||||
|
// straight back, so the two never disagree.
|
||||||
|
private int _currentPage
|
||||||
|
{
|
||||||
|
get => _view.CurrentPage;
|
||||||
|
set { _view.CurrentPage = value; if (PageList.SelectedIndex != value) PageList.SelectedIndex = value; }
|
||||||
|
}
|
||||||
|
private readonly Button _toolSelectBtn = null!;
|
||||||
|
private readonly Button _toolTextBtn = null!;
|
||||||
|
private readonly Button _toolHighlightBtn = null!;
|
||||||
|
private readonly Button _toolUnderlineBtn = null!;
|
||||||
|
private readonly Button _toolDrawBtn = null!;
|
||||||
|
private readonly Button _toolShapeBtn = null!;
|
||||||
|
private readonly Button _toolSignatureBtn = null!;
|
||||||
|
private readonly Button _toolImageBtn = null!;
|
||||||
|
private readonly Button _saveAsBtnRef = null!;
|
||||||
|
private readonly Button _closeFileBtnRef = null!;
|
||||||
|
private readonly ComboBox _zoomBox = null!;
|
||||||
|
private readonly Grid _portableBadge = null!;
|
||||||
|
private readonly TextBox _pageJumpBox = null!;
|
||||||
|
private readonly TextBlock _pageTotalLabel = null!;
|
||||||
|
|
||||||
|
// Dirty / unsaved-change tracking
|
||||||
|
private bool _isDirty { get => ActiveViewer.IsDirtyRef; set => ActiveViewer.IsDirtyRef = value; }
|
||||||
|
|
||||||
|
// Whole-document search results now live on SearchController (Features/Search); Tabs.cs
|
||||||
|
// parks and restores them per tab through its AllSearchRects/ResultPages/PageCursor.
|
||||||
|
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
var v = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
|
||||||
|
if (v != null) VersionLabel.Text = $"v{v.Major}.{v.Minor}.{v.Build}";
|
||||||
|
// Accept dropped files/folders/archives anywhere on the window (not just the empty drop zone),
|
||||||
|
// so dropping onto an open document works too. The empty-state DropZone marks its own drop
|
||||||
|
// handled, so a drop there isn't processed twice.
|
||||||
|
AllowDrop = true;
|
||||||
|
DragOver += DropZone_DragOver;
|
||||||
|
Drop += DropZone_Drop;
|
||||||
|
// Safety net: if the window loses focus mid-drag/resize (e.g. Alt-Tab away to type elsewhere),
|
||||||
|
// the mouse-up can be lost and the dragged annotation would stay glued to the cursor with the
|
||||||
|
// canvas still holding mouse capture. End any in-progress gesture on deactivate so control is
|
||||||
|
// restored the moment the user comes back.
|
||||||
|
Deactivated += (_, _) => { if (_isDraggingAnnot || _isResizingSig) FinishStuckGesture(); };
|
||||||
|
// These three live inside the PdfViewer control, and a UserControl is its own
|
||||||
|
// namescope - FindName would return NULL SILENTLY rather than throw, so the failure
|
||||||
|
// would surface much later as an unrelated NullReference. Take them off the control
|
||||||
|
// directly; they land in ViewerState through the forwarding properties.
|
||||||
|
// Both panes get an Owner and pane A becomes the active one (Shell/SplitPane.cs).
|
||||||
|
// Replaces the single `Viewer.Owner = this` - ViewerB exists from startup, collapsed,
|
||||||
|
// so its bridge would NullReference the moment anything touched it otherwise.
|
||||||
|
InitSplitPanes();
|
||||||
|
WirePageListEdgeFades(); // sidebar page-list edge fades (SidebarLayout.cs)
|
||||||
|
HookExternalLangReload(); // #211: --lang-file live reload rebuilds code-built captions
|
||||||
|
_pageContentGrid = ActiveViewer.PageGrid;
|
||||||
|
_pageContentPanel = ActiveViewer.PageHost;
|
||||||
|
_continuousPanel = ActiveViewer.ContinuousHost;
|
||||||
|
_toolSelectBtn = (Button)FindName("ToolSelectBtn")!;
|
||||||
|
_toolTextBtn = (Button)FindName("ToolTextBtn")!;
|
||||||
|
_toolHighlightBtn = (Button)FindName("ToolHighlightBtn")!;
|
||||||
|
_toolUnderlineBtn = (Button)FindName("ToolUnderlineBtn")!;
|
||||||
|
_toolDrawBtn = (Button)FindName("ToolDrawBtn")!;
|
||||||
|
_toolShapeBtn = (Button)FindName("ToolShapeBtn")!;
|
||||||
|
_toolSignatureBtn = (Button)FindName("ToolSignatureBtn")!;
|
||||||
|
_toolImageBtn = (Button)FindName("ToolImageBtn")!;
|
||||||
|
_toolCropBtn = (Button)FindName("ToolCropBtn")!;
|
||||||
|
_toolRotateBtn = (Button)FindName("ToolRotateBtn")!;
|
||||||
|
_sidebarToggleBtn = (Button)FindName("SidebarToggleBtn")!;
|
||||||
|
_sidebarBorder = (Border)FindName("SidebarBorder")!;
|
||||||
|
_sidebarCol = (ColumnDefinition)FindName("SidebarCol")!;
|
||||||
|
// BuildPrimaryTile + _activeCanvas were here. Both panes now do it for themselves in
|
||||||
|
// InitSplitPanes above (PdfViewer.InitTiles) - calling it here as well would build pane
|
||||||
|
// A a SECOND tile.
|
||||||
|
_saveAsBtnRef = (Button)FindName("SaveAsBtn")!;
|
||||||
|
_closeFileBtnRef = (Button)FindName("CloseFileBtn")!;
|
||||||
|
_zoomBox = (ComboBox)FindName("ZoomBox")!;
|
||||||
|
// Read-only editable combo: hide its text-selection highlight so the displayed % never
|
||||||
|
// looks like selected text after a pick.
|
||||||
|
_zoomBox.Loaded += (_, _) =>
|
||||||
|
{
|
||||||
|
if (_zoomBox.Template?.FindName("PART_EditableTextBox", _zoomBox) is TextBox etb)
|
||||||
|
etb.SelectionBrush = System.Windows.Media.Brushes.Transparent;
|
||||||
|
};
|
||||||
|
_portableBadge = (Grid)FindName("PortableBadge")!;
|
||||||
|
_pageJumpBox = (TextBox)FindName("PageJumpBox")!;
|
||||||
|
_pageTotalLabel = (TextBlock)FindName("PageTotalLabel")!;
|
||||||
|
// Both panes: each tracks the page under its own viewport. Pane B was never wired, so
|
||||||
|
// its page counter, jump box and sidebar selection never moved as it scrolled.
|
||||||
|
Viewer.WireScrollChanged(); // handler moved into the control with the render pipeline
|
||||||
|
ViewerB.WireScrollChanged();
|
||||||
|
PreviewMouseDown += NavHistory_PreviewMouseDown; // mouse back/forward buttons retrace jumps
|
||||||
|
MainContentGrid.SizeChanged += (_, _) => ScheduleFadeRefresh();
|
||||||
|
// The sidebar column resizes via the splitter / collapse; track its width so the tab-strip
|
||||||
|
// shadow gradient stays clipped to the document column.
|
||||||
|
if (FindName("SidebarOuterGrid") is FrameworkElement sidebarOuter)
|
||||||
|
sidebarOuter.SizeChanged += (_, _) => ScheduleFadeRefresh();
|
||||||
|
// The footer shadow tracks the document pane's actual position; re-anchor when it (or the
|
||||||
|
// tab strip, which shifts the document) changes size.
|
||||||
|
DocPaneBorder.SizeChanged += (_, _) => ScheduleFadeRefresh();
|
||||||
|
Viewer.SizeChanged += (_, _) => ScheduleFadeRefresh();
|
||||||
|
ViewerB.SizeChanged += (_, _) => ScheduleFadeRefresh();
|
||||||
|
TabStripBorder.SizeChanged += (_, _) => { ScheduleFadeRefresh(); ScheduleTabReflow(); };
|
||||||
|
// After a sidebar-splitter drag, snap fully closed if dragged too narrow, else save the width.
|
||||||
|
SidebarSplitter.PreviewMouseLeftButtonUp += (_, _) => OnSidebarResized();
|
||||||
|
// Grabbing the splitter while collapsed reveals the page list so it can be pulled open.
|
||||||
|
SidebarSplitter.PreviewMouseLeftButtonDown += (_, _) => { _sidebarWantClose = false; OnSidebarSplitterPress(); };
|
||||||
|
// Pull the splitter well past the minimum mid-drag to close (the column itself can't clip).
|
||||||
|
SidebarSplitter.PreviewMouseMove += OnSidebarSplitterMove;
|
||||||
|
// If a drag is interrupted (alt-tab, focus loss, taking a screenshot), finalize it so the
|
||||||
|
// sidebar can't get stuck half-open with its content hidden.
|
||||||
|
SidebarSplitter.LostMouseCapture += (_, _) => OnSidebarResized();
|
||||||
|
if (Enum.TryParse<ViewMode>(App.GetSetting("ViewMode"), out var savedVm))
|
||||||
|
_viewMode = savedVm;
|
||||||
|
InitToolbarStyle(); // two-axis toolbar appearance (+ migration from the old five-way key)
|
||||||
|
InitAppScale(); // AppScale.cs: restore the app-wide size (scroll the logo to change it)
|
||||||
|
// #135: document dark mode. Per PANE now; the saved setting seeds the primary pane
|
||||||
|
// (a split's second pane starts normal - inverting it is a per-pane choice).
|
||||||
|
Viewer.DocInvert = App.GetSetting("DocInvert") == "1";
|
||||||
|
BitmapHelpers.DocInvertImages = App.GetSetting("DocInvertImages") == "1"; // moon right-click opt-in
|
||||||
|
DocInvertBtn.Tag = Viewer.DocInvert ? "on" : null; // rail moon lit while active
|
||||||
|
// #146: the privacy toggle lives in the About window; init once - only its own
|
||||||
|
// handler changes it afterwards (change-guarded, so this init is a no-op there).
|
||||||
|
NoRecentCheck.IsChecked = App.GetSetting(App.NoRecentFilesSetting) == "1";
|
||||||
|
// Same deal for the link-confirm toggle beside it (default off).
|
||||||
|
LinkConfirmCheck.IsChecked = App.GetSetting(ConfirmLinksSetting) == "1";
|
||||||
|
if (string.Equals(App.GetSetting("SidebarSide"), "Right", StringComparison.OrdinalIgnoreCase))
|
||||||
|
_sidebarRight = true;
|
||||||
|
RestoreToolSettings(); // Draw + Text tool styles carry across sessions
|
||||||
|
Loaded += (_, _) => AdjustZoomBoxWidth(); // fit the zoom box to the longest localized term
|
||||||
|
IndexToolbarButtons();
|
||||||
|
OutlineTree.SelectedItemChanged += OutlineTree_SelectedItemChanged;
|
||||||
|
LoadSignatures();
|
||||||
|
BuildContextMenu();
|
||||||
|
SetTool(EditTool.Select);
|
||||||
|
ApplyGrainTexture();
|
||||||
|
ApplyToolNumberTooltips(); // append the 1-9 toolbar positions to the tool tooltips
|
||||||
|
BuildShortcutsOverlay(); // generate the shortcuts card from the single-source table (ShortcutsOverlay.cs)
|
||||||
|
SourceInitialized += MainWindow_SourceInitialized;
|
||||||
|
Closed += (_, _) => { _continuousRenderCts?.Cancel(); _doc?.Close(); CloseLinkPdfiumDoc(); App.CleanupSessionTemps(); };
|
||||||
|
|
||||||
|
// Open a file passed via command-line / file association (e.g. double-clicking a .pdf)
|
||||||
|
// Also show the portable badge when running outside the install location.
|
||||||
|
bool contentRevealed = false;
|
||||||
|
bool startupSidebarSynced = false;
|
||||||
|
ContentRendered += (_, _) =>
|
||||||
|
{
|
||||||
|
Services.StartupTrace.Mark("MainWindow first ContentRendered");
|
||||||
|
Services.ThemeManager.RefreshIcons();
|
||||||
|
// Startup can restore pane B, process a relaunch/open-file handoff, and change
|
||||||
|
// ActiveViewer several times before the first frame. Each transition is valid on
|
||||||
|
// its own, but the shared sidebar ItemsSource can still be the preceding pane's
|
||||||
|
// cache (or null) when layout finally wins the race. A click appeared to "bring
|
||||||
|
// thumbnails back" because FocusPane performs this same synchronization. Do it
|
||||||
|
// once here for the pane whose focus halo actually reaches the screen.
|
||||||
|
if (!startupSidebarSynced)
|
||||||
|
{
|
||||||
|
startupSidebarSynced = true;
|
||||||
|
RestorePageListForActivePane();
|
||||||
|
}
|
||||||
|
// Final pass once the layout has real widths. The tab-strip / footer shadow gradients
|
||||||
|
// were intermittently blank at startup (their feather mask + margin were computed
|
||||||
|
// before the sidebar column had measured), and only a manual sidebar tweak forced a
|
||||||
|
// correct re-layout. Re-running it here reproduces that fix automatically.
|
||||||
|
UpdateTabStripFade();
|
||||||
|
// The content is held invisible (RootClipGrid.Opacity=0 in XAML) until this final
|
||||||
|
// positioning pass has run; fade it in once so the brief unpositioned first frame
|
||||||
|
// (the "load deform" - shadows/toolbars snapping into place) is never visible.
|
||||||
|
if (!contentRevealed)
|
||||||
|
{
|
||||||
|
contentRevealed = true;
|
||||||
|
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)(() =>
|
||||||
|
{
|
||||||
|
var reveal = new System.Windows.Media.Animation.DoubleAnimation(0, 1,
|
||||||
|
new Duration(TimeSpan.FromMilliseconds(140)));
|
||||||
|
RootClipGrid.BeginAnimation(OpacityProperty, reveal);
|
||||||
|
Services.StartupTrace.Mark("MainWindow ready");
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Services.ThemeManager.ThemeChanged += OnThemeChanged;
|
||||||
|
|
||||||
|
Loaded += (_, _) =>
|
||||||
|
{
|
||||||
|
RestoreWindowSettings();
|
||||||
|
ApplySidebarSide(); // place the sidebar on the saved side (default left)
|
||||||
|
BuildToolbarMenu(); // right-click appearance picker on the toolbar
|
||||||
|
// Unconditional: the XAML default is small icons / no text, but the family default
|
||||||
|
// is Large/Under, so a first run needs the apply pass too.
|
||||||
|
ApplyToolbarAppearance();
|
||||||
|
|
||||||
|
FlushPendingExternalOpen(); // a forward that landed before the panes were wired
|
||||||
|
|
||||||
|
var args = Environment.GetCommandLineArgs();
|
||||||
|
if (args.Length > 1 && (System.IO.File.Exists(args[1]) ||
|
||||||
|
Services.ProtocolRegistrar.TryGetTargetUrl(args[1], out _)))
|
||||||
|
{
|
||||||
|
OpenFromExternal(args[1]);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Reopen every tab from the last session (falls back to the single LastFile for
|
||||||
|
// settings written before multi-tab restore existed).
|
||||||
|
var saved = App.GetSetting("OpenTabs");
|
||||||
|
string[] paths = !string.IsNullOrEmpty(saved)
|
||||||
|
? saved!.Split('|')
|
||||||
|
: (App.GetSetting("LastFile") is { Length: > 0 } lf ? [lf] : []);
|
||||||
|
// Lazy restore: create a placeholder tab for each saved file but load only the
|
||||||
|
// focused one. The rest materialize (load + render) the first time they're clicked,
|
||||||
|
// so startup cost no longer scales with how many tabs were open last session.
|
||||||
|
// Built as a local list and handed to the pane, rather than mutating _sessions
|
||||||
|
// in place: the session list belongs to a PdfViewer, so the restore has to say
|
||||||
|
// WHICH pane. `OpenTabs` / `ActiveTab` are pane A; pane B is restored from
|
||||||
|
// `OpenTabsB` / `ActiveTabB` by RestorePaneB below, once the split is reopened.
|
||||||
|
var restored = new List<Controls.PdfViewer.DocumentSession>();
|
||||||
|
foreach (var f in paths)
|
||||||
|
if (!string.IsNullOrEmpty(f) && System.IO.File.Exists(f))
|
||||||
|
restored.Add(Controls.PdfViewer.MakeDeferredSession(f));
|
||||||
|
|
||||||
|
if (restored.Count == 0)
|
||||||
|
{
|
||||||
|
SetRestoredSessions(restored, null);
|
||||||
|
PopulateRecentFilesList(); // empty state: show the recent list
|
||||||
|
EnsureInitialSession();
|
||||||
|
RebuildTabStrip();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var wantActive = App.GetSetting("ActiveTab");
|
||||||
|
var activeTarget = (!string.IsNullOrEmpty(wantActive)
|
||||||
|
? restored.FirstOrDefault(ss => string.Equals(ss.OriginalFile, wantActive, StringComparison.OrdinalIgnoreCase))
|
||||||
|
: null)
|
||||||
|
?? restored[0];
|
||||||
|
SetRestoredSessions(restored, activeTarget);
|
||||||
|
ApplySessionState(activeTarget);
|
||||||
|
MaterializeDeferred(activeTarget); // load + render only the focused tab
|
||||||
|
RebuildTabStrip();
|
||||||
|
}
|
||||||
|
RestorePaneB();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (App.IsPortable())
|
||||||
|
_portableBadge.Visibility = Visibility.Visible;
|
||||||
|
|
||||||
|
// Start with the sidebar collapsed when no PDF is open (nothing to show); a document
|
||||||
|
// opened above will have expanded it via FinishOpenFile.
|
||||||
|
SyncSidebarToDocState(hasDoc: _doc != null, startup: true);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Maximize-respects-taskbar fix (WindowStyle=None needs WM_GETMINMAXINFO)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
private void MainWindow_SourceInitialized(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
var hwnd = new WindowInteropHelper(this).Handle;
|
||||||
|
HwndSource.FromHwnd(hwnd)?.AddHook(WndProc);
|
||||||
|
ThemeManager.ApplyDwm(hwnd);
|
||||||
|
// Snapping moves the window without changing WindowState, so re-evaluate the rounded vs
|
||||||
|
// squared chrome on every move (and once now that the handle exists).
|
||||||
|
LocationChanged += OnWindowLocationChanged;
|
||||||
|
UpdateWindowChrome();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Settings persistence (window size, zoom, last file)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
private void SaveWindowSettings()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
App.SetSetting("WindowState", WindowState.ToString());
|
||||||
|
if (WindowState == WindowState.Normal)
|
||||||
|
{
|
||||||
|
App.SetSetting("WindowWidth", ((int)ActualWidth).ToString());
|
||||||
|
App.SetSetting("WindowHeight", ((int)ActualHeight).ToString());
|
||||||
|
App.SetSetting("WindowTop", ((int)Top).ToString());
|
||||||
|
App.SetSetting("WindowLeft", ((int)Left).ToString());
|
||||||
|
}
|
||||||
|
App.SetSetting("FitMode", _fitMode.ToString());
|
||||||
|
App.SetSetting("ZoomLevel", _zoomLevel.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
// #105: honor the "remember open files" privacy choice. Unset or "1" = remember
|
||||||
|
// (default, preserves prior behavior); "0" = forget the session so nothing persists.
|
||||||
|
bool rememberFiles = App.GetSetting("RememberOpenFiles") != "0";
|
||||||
|
if (rememberFiles)
|
||||||
|
{
|
||||||
|
if (_currentFile is not null)
|
||||||
|
App.SetSetting("LastFile", _currentFile);
|
||||||
|
else
|
||||||
|
App.RemoveSetting("LastFile");
|
||||||
|
// Remember every open tab so the whole session restores next launch. Manually-closed
|
||||||
|
// tabs are already gone from _sessions, so they won't come back (Issue #75 still holds).
|
||||||
|
// Saved PER PANE. `OpenTabs` / `ActiveTab` keep their old meaning - pane A - so
|
||||||
|
// settings written before the split existed still restore; pane B gets its own
|
||||||
|
// `OpenTabsB` / `ActiveTabB`, and the split itself gets `SplitOpen` and the
|
||||||
|
// divider position. Without this a split window reopened with everything
|
||||||
|
// stacked in pane A.
|
||||||
|
static List<string> FilesOf(Controls.PdfViewer pane) => pane.SessionsRef
|
||||||
|
.Select(ss => ss.OriginalFile)
|
||||||
|
.Where(f => !string.IsNullOrEmpty(f) && System.IO.File.Exists(f))
|
||||||
|
.Distinct()
|
||||||
|
.Select(f => f!)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
static void SavePane(string tabsKey, string activeKey,
|
||||||
|
List<string> files, Controls.PdfViewer pane)
|
||||||
|
{
|
||||||
|
if (files.Count > 0) App.SetSetting(tabsKey, string.Join("|", files));
|
||||||
|
else App.RemoveSetting(tabsKey);
|
||||||
|
if (pane.ActiveSessionRef?.OriginalFile is { Length: > 0 } af
|
||||||
|
&& System.IO.File.Exists(af)) App.SetSetting(activeKey, af);
|
||||||
|
else App.RemoveSetting(activeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
SavePane("OpenTabs", "ActiveTab", FilesOf(Viewer), Viewer);
|
||||||
|
SavePane("OpenTabsB", "ActiveTabB", FilesOf(ViewerB), ViewerB);
|
||||||
|
|
||||||
|
if (IsSplit)
|
||||||
|
{
|
||||||
|
App.SetSetting("SplitOpen", "1");
|
||||||
|
// The pixel width of pane A - the fixed column; pane B is star-sized and
|
||||||
|
// takes the remainder, so one number describes the divider whatever the
|
||||||
|
// window is resized to. (Used to save pane B's width instead, which is the
|
||||||
|
// derived/remainder side and not what the restore path needs to seed pane A
|
||||||
|
// with before the window has laid out - #161, split pane not remembering
|
||||||
|
// its size across a restart.)
|
||||||
|
double aw = Viewer.ActualWidth;
|
||||||
|
if (aw > 0) App.SetSetting("SplitPaneAWidth",
|
||||||
|
aw.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
App.RemoveSetting("SplitOpen");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Privacy: drop any remembered session so no file paths linger on disk.
|
||||||
|
App.RemoveSetting("LastFile");
|
||||||
|
App.RemoveSetting("OpenTabs");
|
||||||
|
App.RemoveSetting("ActiveTab");
|
||||||
|
App.RemoveSetting("OpenTabsB");
|
||||||
|
App.RemoveSetting("ActiveTabB");
|
||||||
|
App.RemoveSetting("SplitOpen");
|
||||||
|
}
|
||||||
|
PersistToolSettings();
|
||||||
|
// The active tab may not have been captured yet at exit; persist its view state directly.
|
||||||
|
if (_active != null)
|
||||||
|
SaveDocState(_originalFile, _fitMode, _zoomLevel, _viewMode,
|
||||||
|
PageList.SelectedIndex >= 0 ? PageList.SelectedIndex : 0);
|
||||||
|
}
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the Draw and Text tool styles so they carry across sessions. The eraser toggle is
|
||||||
|
// deliberately NOT saved (it's a transient mode, not a style).
|
||||||
|
private void PersistToolSettings()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
App.SetSetting("DrawColor", ToolColorHex(_drawColor));
|
||||||
|
App.SetSetting("DrawWidth", _drawWidth.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
App.SetSetting("DrawOpacity", _drawOpacity.ToString());
|
||||||
|
App.SetSetting("TextFontSize", _textFontSize.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
App.SetSetting("TextFontName", _textFontName);
|
||||||
|
App.SetSetting("TextBold", _textBold ? "1" : "0");
|
||||||
|
App.SetSetting("TextItalic", _textItalic ? "1" : "0");
|
||||||
|
App.SetSetting("TextStrike", _textStrike ? "1" : "0");
|
||||||
|
App.SetSetting("TextUnderline", _textUnderline ? "1" : "0");
|
||||||
|
App.SetSetting("TextColor", ToolColorHex(_textColor));
|
||||||
|
App.SetSetting("TextOpacity", _textOpacity.ToString());
|
||||||
|
App.SetSetting("TextFillColor", ToolColorHexA(_textFillColor));
|
||||||
|
}
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RestoreToolSettings()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (ParseToolColor(App.GetSetting("DrawColor")) is Color dc) _drawColor = dc;
|
||||||
|
if (double.TryParse(App.GetSetting("DrawWidth"), System.Globalization.NumberStyles.Float,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture, out double dw) && dw > 0) _drawWidth = dw;
|
||||||
|
if (byte.TryParse(App.GetSetting("DrawOpacity"), out byte dop)) _drawOpacity = dop;
|
||||||
|
|
||||||
|
if (double.TryParse(App.GetSetting("TextFontSize"), System.Globalization.NumberStyles.Float,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture, out double tfs) && tfs > 0) _textFontSize = tfs;
|
||||||
|
if (App.GetSetting("TextFontName") is { Length: > 0 } tfn) _textFontName = tfn;
|
||||||
|
_textBold = App.GetSetting("TextBold") == "1";
|
||||||
|
_textItalic = App.GetSetting("TextItalic") == "1";
|
||||||
|
_textStrike = App.GetSetting("TextStrike") == "1";
|
||||||
|
_textUnderline = App.GetSetting("TextUnderline") == "1";
|
||||||
|
if (ParseToolColor(App.GetSetting("TextColor")) is Color tc) _textColor = tc;
|
||||||
|
if (byte.TryParse(App.GetSetting("TextOpacity"), out byte top)) _textOpacity = top;
|
||||||
|
if (ParseToolColorA(App.GetSetting("TextFillColor")) is Color tfc) _textFillColor = tfc;
|
||||||
|
|
||||||
|
// Keep each color's alpha in sync with its opacity byte (the bars store them coupled).
|
||||||
|
_drawColor = Color.FromArgb(_drawOpacity, _drawColor.R, _drawColor.G, _drawColor.B);
|
||||||
|
_textColor = Color.FromArgb(_textOpacity, _textColor.R, _textColor.G, _textColor.B);
|
||||||
|
}
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToolColorHex(Color c) => $"#{c.R:X2}{c.G:X2}{c.B:X2}";
|
||||||
|
private static string ToolColorHexA(Color c) => $"#{c.A:X2}{c.R:X2}{c.G:X2}{c.B:X2}";
|
||||||
|
|
||||||
|
private static Color? ParseToolColor(string? s)
|
||||||
|
{
|
||||||
|
if (s is null || s.Length != 7 || s[0] != '#') return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Color.FromRgb(Convert.ToByte(s.Substring(1, 2), 16),
|
||||||
|
Convert.ToByte(s.Substring(3, 2), 16),
|
||||||
|
Convert.ToByte(s.Substring(5, 2), 16));
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color? ParseToolColorA(string? s)
|
||||||
|
{
|
||||||
|
if (s is null || s.Length != 9 || s[0] != '#') return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Color.FromArgb(Convert.ToByte(s.Substring(1, 2), 16),
|
||||||
|
Convert.ToByte(s.Substring(3, 2), 16),
|
||||||
|
Convert.ToByte(s.Substring(5, 2), 16),
|
||||||
|
Convert.ToByte(s.Substring(7, 2), 16));
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RestoreWindowSettings()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (int.TryParse(App.GetSetting("WindowWidth"), out int w) &&
|
||||||
|
int.TryParse(App.GetSetting("WindowHeight"), out int h) && w > 200 && h > 200)
|
||||||
|
{
|
||||||
|
Width = w;
|
||||||
|
Height = h;
|
||||||
|
}
|
||||||
|
if (int.TryParse(App.GetSetting("WindowTop"), out int savedTop) &&
|
||||||
|
int.TryParse(App.GetSetting("WindowLeft"), out int savedLeft))
|
||||||
|
{
|
||||||
|
// Verify the saved position is visible on the virtual desktop
|
||||||
|
// (covers all monitors). Falls back to CenterScreen (XAML default)
|
||||||
|
// if the monitor it was on is no longer connected.
|
||||||
|
double vLeft = SystemParameters.VirtualScreenLeft;
|
||||||
|
double vTop = SystemParameters.VirtualScreenTop;
|
||||||
|
double vRight = vLeft + SystemParameters.VirtualScreenWidth;
|
||||||
|
double vBottom = vTop + SystemParameters.VirtualScreenHeight;
|
||||||
|
bool onScreen = savedLeft + 100 < vRight && savedLeft + Width > vLeft
|
||||||
|
&& savedTop + 50 < vBottom && savedTop + Height > vTop;
|
||||||
|
if (onScreen)
|
||||||
|
{
|
||||||
|
Left = savedLeft;
|
||||||
|
Top = savedTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Enum.TryParse<WindowState>(App.GetSetting("WindowState"), out var ws) &&
|
||||||
|
ws == WindowState.Maximized)
|
||||||
|
{
|
||||||
|
WindowState = WindowState.Maximized;
|
||||||
|
}
|
||||||
|
if (double.TryParse(App.GetSetting("ZoomLevel"),
|
||||||
|
System.Globalization.NumberStyles.Float,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture, out double z) && z > 0)
|
||||||
|
{
|
||||||
|
_zoomLevel = Math.Max(ZoomMin, Math.Min(ZoomMax, z));
|
||||||
|
}
|
||||||
|
if (Enum.TryParse<FitMode>(App.GetSetting("FitMode"), out var fm))
|
||||||
|
_fitMode = fm;
|
||||||
|
}
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Core helpers (localization, status, PDF object refs)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// <summary>Look up a localized string. Falls back to the key name if missing.</summary>
|
||||||
|
private string Loc(string key)
|
||||||
|
=> Application.Current.TryFindResource(key) as string ?? key;
|
||||||
|
|
||||||
|
// A "held" status message briefly wins over routine updates: scrolling the logo to
|
||||||
|
// resize the app must show "App size N%", but the chrome resize immediately re-runs
|
||||||
|
// the fit pipeline, whose "Page x of y - Fit Page" status stomped it the same frame.
|
||||||
|
// While the hold is active, plain SetStatus calls are ignored; the hold refreshes on
|
||||||
|
// every wheel notch and expires on its own, after which normal statuses flow again.
|
||||||
|
private DateTime _statusHoldUntil = DateTime.MinValue;
|
||||||
|
|
||||||
|
private void SetStatus(string text)
|
||||||
|
{
|
||||||
|
if (DateTime.UtcNow < _statusHoldUntil) return; // a held message is showing
|
||||||
|
StatusText.Text = text;
|
||||||
|
CrashReporter.PushStatusMessage(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetStatusHeld(string text, int holdMs = 1200)
|
||||||
|
{
|
||||||
|
_statusHoldUntil = DateTime.UtcNow.AddMilliseconds(holdMs);
|
||||||
|
StatusText.Text = text;
|
||||||
|
CrashReporter.PushStatusMessage(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clicking the status line flashes the open document's file size for a beat, then puts
|
||||||
|
// back whatever was showing (requested on Reddit). Held so page-change chatter can't
|
||||||
|
// overwrite it mid-read; the restore stands down if a newer held message took over.
|
||||||
|
private void StatusText_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||||
|
=> ShowCurrentFileSize();
|
||||||
|
|
||||||
|
private void ShowCurrentFileSize()
|
||||||
|
{
|
||||||
|
string? path = _originalFile ?? _currentFile;
|
||||||
|
if (path is null || !System.IO.File.Exists(path)) return;
|
||||||
|
string prior = StatusText.Text;
|
||||||
|
long bytes = new System.IO.FileInfo(path).Length;
|
||||||
|
string size = bytes >= 1L << 20 ? $"{bytes / (double)(1 << 20):0.##} MB"
|
||||||
|
: bytes >= 1L << 10 ? $"{bytes / (double)(1 << 10):0.#} KB"
|
||||||
|
: $"{bytes} B";
|
||||||
|
SetStatusHeld($"{System.IO.Path.GetFileName(path)} - {size}", 2500);
|
||||||
|
var restore = new System.Windows.Threading.DispatcherTimer
|
||||||
|
{ Interval = TimeSpan.FromMilliseconds(2550) };
|
||||||
|
restore.Tick += (_, _) =>
|
||||||
|
{
|
||||||
|
restore.Stop();
|
||||||
|
if (DateTime.UtcNow >= _statusHoldUntil) SetStatus(prior);
|
||||||
|
};
|
||||||
|
restore.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dereferences a PdfItem if it is an indirect reference (PdfReference is internal;
|
||||||
|
/// we detect it by looking for a public "Value" property returning PdfObject).
|
||||||
|
/// </summary>
|
||||||
|
private static PdfItem DerefItem(PdfItem item)
|
||||||
|
{
|
||||||
|
var valueProp = item.GetType().GetProperty("Value",
|
||||||
|
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
|
||||||
|
if (valueProp?.GetValue(item) is PdfObject resolved)
|
||||||
|
return resolved;
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetObjectNumber lives in Services/PdfScrub.cs (KillerUI refactor), beside
|
||||||
|
// DerefItemStatic - the same reflection-over-PdfReference family.
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Search (Ctrl+F)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a collection of PdfPig words to a properly ordered string.
|
||||||
|
/// Sorts top-to-bottom then left-to-right, groups into lines using a
|
||||||
|
/// dynamic threshold (~40% of average word height) so words at slightly
|
||||||
|
/// different baselines still land on the correct line.
|
||||||
|
/// </summary>
|
||||||
|
private static string WordsToText(IEnumerable<UglyToad.PdfPig.Content.Word> source)
|
||||||
|
{
|
||||||
|
var words = source
|
||||||
|
.OrderByDescending(w => w.BoundingBox.Top)
|
||||||
|
.ThenBy(w => w.BoundingBox.Left)
|
||||||
|
.ToList();
|
||||||
|
if (words.Count == 0) return string.Empty;
|
||||||
|
|
||||||
|
// Dynamic threshold: 40% of average word height, minimum 4 PDF units
|
||||||
|
double avgH = words.Average(w => w.BoundingBox.Height);
|
||||||
|
double thresh = Math.Max(4.0, avgH * 0.4);
|
||||||
|
|
||||||
|
var lines = new List<List<UglyToad.PdfPig.Content.Word>>();
|
||||||
|
double lineY = double.MaxValue;
|
||||||
|
foreach (var w in words)
|
||||||
|
{
|
||||||
|
if (Math.Abs(w.BoundingBox.Top - lineY) > thresh)
|
||||||
|
{
|
||||||
|
lines.Add([]);
|
||||||
|
lineY = w.BoundingBox.Top;
|
||||||
|
}
|
||||||
|
lines[^1].Add(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-sort each line by X in case the top-Y sort caused any grouping
|
||||||
|
// to pull words into the wrong order within a line.
|
||||||
|
return string.Join("\n", lines.Select(l =>
|
||||||
|
string.Join(" ", l.OrderBy(w => w.BoundingBox.Left).Select(w => w.Text))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
using System;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
public enum EditTool { Select, Text, Highlight, Strikethrough, Underline, Draw, Signature, Image, Crop, Line, Rotate, Shape }
|
||||||
|
|
||||||
|
/// <summary>Sub-mode of the Shapes tool (#127 Phase 3): drag a rectangle or ellipse, or click
|
||||||
|
/// out a free-form polygon vertex by vertex.</summary>
|
||||||
|
public enum ShapeKind { Rectangle, Ellipse, Polygon }
|
||||||
|
|
||||||
|
/// <summary>How a HighlightAnnotation paints over its bounds.</summary>
|
||||||
|
public enum HighlightStyle { Fill, Strikethrough, Underline }
|
||||||
|
|
||||||
|
public abstract class PageAnnotation
|
||||||
|
{
|
||||||
|
public int PageIndex { get; set; }
|
||||||
|
// Links a text-edit cover to its replacement text (same non-empty id on both). A cover with a
|
||||||
|
// PairId renders dashed (it's "paired"); when the partner text is deleted the cover's PairId is
|
||||||
|
// cleared and it renders as a solid box. Empty for everything else.
|
||||||
|
public string PairId { get; set; } = "";
|
||||||
|
|
||||||
|
// Groups arbitrary annotations so they select and move together (same non-empty id on every
|
||||||
|
// member). Independent of PairId. Empty when the annotation isn't grouped.
|
||||||
|
public string GroupId { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for placed/resizable annotations (signature, image).
|
||||||
|
/// Carries the shared position, scale, and source-dimension properties used by the resize handle.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class PlacedAnnotation : PageAnnotation
|
||||||
|
{
|
||||||
|
public Point Position { get; set; }
|
||||||
|
public double Scale { get; set; } = 0.5;
|
||||||
|
public double SourceWidth { get; set; } = 400;
|
||||||
|
public double SourceHeight { get; set; } = 150;
|
||||||
|
|
||||||
|
// Runtime-only cache of the decoded image (for image signatures / placed images). Held in
|
||||||
|
// memory so a resize-drag doesn't re-decode the Base64 on every mouse tick. Not serialized;
|
||||||
|
// the immutable ImageData stays the source of truth.
|
||||||
|
public System.Windows.Media.Imaging.BitmapSource? CachedBitmap;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TextAnnotation : PageAnnotation
|
||||||
|
{
|
||||||
|
public Point Position { get; set; }
|
||||||
|
public string Content { get; set; } = "";
|
||||||
|
public double FontSize { get; set; } = 14;
|
||||||
|
// Typeface and style. FontName is a font-family name (any installed system font). Bold/Italic/Strike
|
||||||
|
// apply to the whole box. Defaults keep text placed before these existed rendering as plain Segoe UI.
|
||||||
|
public string FontName { get; set; } = "Segoe UI";
|
||||||
|
public bool Bold { get; set; }
|
||||||
|
public bool Italic { get; set; }
|
||||||
|
public bool Strike { get; set; }
|
||||||
|
public bool Underline { get; set; }
|
||||||
|
public byte ColorR { get; set; } = 0;
|
||||||
|
public byte ColorG { get; set; } = 0;
|
||||||
|
public byte ColorB { get; set; } = 0;
|
||||||
|
public byte ColorA { get; set; } = 255;
|
||||||
|
|
||||||
|
// Box geometry. Width is fixed (text wraps to it); Height auto-grows to fit the wrapped text.
|
||||||
|
public double Width { get; set; } = 200;
|
||||||
|
public double Height { get; set; } = 28;
|
||||||
|
|
||||||
|
// Optional background fill (the "whiteout"/highlight behind the text). BgA == 0 means no fill.
|
||||||
|
public byte BgR { get; set; } = 255;
|
||||||
|
public byte BgG { get; set; } = 255;
|
||||||
|
public byte BgB { get; set; } = 255;
|
||||||
|
public byte BgA { get; set; } = 0;
|
||||||
|
|
||||||
|
public Color GetColor() => Color.FromArgb(ColorA, ColorR, ColorG, ColorB);
|
||||||
|
public void SetColor(Color c) { ColorR = c.R; ColorG = c.G; ColorB = c.B; ColorA = c.A; }
|
||||||
|
|
||||||
|
public Color GetFill() => Color.FromArgb(BgA, BgR, BgG, BgB);
|
||||||
|
public void SetFill(Color c) { BgR = c.R; BgG = c.G; BgB = c.B; BgA = c.A; }
|
||||||
|
public bool HasFill => BgA > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class InkAnnotation : PageAnnotation
|
||||||
|
{
|
||||||
|
public List<Point> Points { get; set; } = [];
|
||||||
|
public double StrokeWidth { get; set; } = 2;
|
||||||
|
public byte ColorR { get; set; } = 255;
|
||||||
|
public byte ColorG { get; set; } = 0;
|
||||||
|
public byte ColorB { get; set; } = 0;
|
||||||
|
public byte ColorA { get; set; } = 255;
|
||||||
|
|
||||||
|
public Color GetColor() => Color.FromArgb(ColorA, ColorR, ColorG, ColorB);
|
||||||
|
public void SetColor(Color c) { ColorR = c.R; ColorG = c.G; ColorB = c.B; ColorA = c.A; }
|
||||||
|
|
||||||
|
// Shapes (#127 Phase 3): a non-zero alpha fills the region enclosed by the stroke (the
|
||||||
|
// Shapes tool commits closed outlines - the last point repeats the first). Plain ink
|
||||||
|
// strokes and lines leave FillA = 0 and render exactly as before.
|
||||||
|
public byte FillR { get; set; }
|
||||||
|
public byte FillG { get; set; }
|
||||||
|
public byte FillB { get; set; }
|
||||||
|
public byte FillA { get; set; }
|
||||||
|
|
||||||
|
public bool HasFill => FillA > 0;
|
||||||
|
public Color GetFillColor() => Color.FromArgb(FillA, FillR, FillG, FillB);
|
||||||
|
public void SetFillColor(Color c) { FillR = c.R; FillG = c.G; FillB = c.B; FillA = c.A; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// One brush-eraser pass over a highlight: a stroke (canvas-space points) of the given radius. The
|
||||||
|
// highlight renders as its rectangle MINUS the union of these widened strokes - one anti-aliased
|
||||||
|
// geometry, so the erased edges are smooth curves, not blocky steps or seamed strips.
|
||||||
|
public sealed class HighlightErase
|
||||||
|
{
|
||||||
|
public System.Collections.Generic.List<Point> Points { get; set; } = [];
|
||||||
|
public double Radius { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class HighlightAnnotation : PageAnnotation
|
||||||
|
{
|
||||||
|
public Rect Bounds { get; set; }
|
||||||
|
// Brush-eraser passes carved out of this highlight (null = untouched solid rect). Only Fill-style
|
||||||
|
// highlights are ever carved.
|
||||||
|
public System.Collections.Generic.List<HighlightErase>? Erases { get; set; }
|
||||||
|
public HighlightStyle Style { get; set; } = HighlightStyle.Fill;
|
||||||
|
public byte ColorR { get; set; } = 255;
|
||||||
|
public byte ColorG { get; set; } = 255;
|
||||||
|
public byte ColorB { get; set; } = 0;
|
||||||
|
public byte ColorA { get; set; } = 80;
|
||||||
|
|
||||||
|
public Color GetColor() => Color.FromArgb(ColorA, ColorR, ColorG, ColorB);
|
||||||
|
public virtual void SetColor(Color c) { ColorR = c.R; ColorG = c.G; ColorB = c.B; ColorA = c.A; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The actual rectangle painted for this annotation. Fill uses the whole bounds;
|
||||||
|
/// strikethrough is a thin band at the vertical center; underline sits at the bottom.
|
||||||
|
/// </summary>
|
||||||
|
public Rect DrawRect()
|
||||||
|
{
|
||||||
|
double t = Math.Max(2.0, Bounds.Height * 0.10);
|
||||||
|
switch (Style)
|
||||||
|
{
|
||||||
|
case HighlightStyle.Strikethrough:
|
||||||
|
return new Rect(Bounds.X, Bounds.Y + Bounds.Height / 2 - t / 2, Bounds.Width, t);
|
||||||
|
case HighlightStyle.Underline:
|
||||||
|
return new Rect(Bounds.X, Bounds.Y + Bounds.Height - t, Bounds.Width, t);
|
||||||
|
default:
|
||||||
|
return Bounds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An opaque filled rectangle that covers ("erases") existing PDF content - the background half
|
||||||
|
/// of a text edit. Subclasses HighlightAnnotation so it inherits all rect plumbing (render, drag,
|
||||||
|
/// corner-resize, hit-test, export) for free; the only differences are an opaque default fill and
|
||||||
|
/// a SetColor that can never go translucent (a see-through cover would let the old text ghost
|
||||||
|
/// through, the exact bug this feature exists to avoid). The paired replacement text is a normal
|
||||||
|
/// TextAnnotation placed on top, so it is independently editable, movable, and recolorable.
|
||||||
|
/// </summary>
|
||||||
|
public class CoverAnnotation : HighlightAnnotation
|
||||||
|
{
|
||||||
|
public CoverAnnotation()
|
||||||
|
{
|
||||||
|
ColorR = 255; ColorG = 255; ColorB = 255; ColorA = 255; // opaque white by default
|
||||||
|
Style = HighlightStyle.Fill;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Recolor the cover but keep it fully opaque - drop any alpha the caller passed.</summary>
|
||||||
|
public override void SetColor(Color c) => base.SetColor(Color.FromArgb(255, c.R, c.G, c.B));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A signature placed on a PDF page: either ink strokes or an imported image.
|
||||||
|
/// </summary>
|
||||||
|
public class SignatureAnnotation : PlacedAnnotation
|
||||||
|
{
|
||||||
|
public List<List<Point>> Strokes { get; set; } = [];
|
||||||
|
/// <summary>Pen thickness (DIPs at source scale); multiplied by Scale when rendered.</summary>
|
||||||
|
public double StrokeWidth { get; set; } = 2.5;
|
||||||
|
/// <summary>Base-64 encoded PNG. Non-null = image sig; null = drawn strokes.</summary>
|
||||||
|
public string? ImageData { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An image placed on a PDF page as a resizable annotation.
|
||||||
|
/// </summary>
|
||||||
|
public class ImageAnnotation : PlacedAnnotation
|
||||||
|
{
|
||||||
|
/// <summary>Base-64 encoded image bytes (PNG, JPG, BMP, etc.).</summary>
|
||||||
|
public string ImageData { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A point that can be serialized to JSON (WPF Point doesn't serialize well).
|
||||||
|
/// </summary>
|
||||||
|
public class SerializablePoint
|
||||||
|
{
|
||||||
|
public double X { get; set; }
|
||||||
|
public double Y { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A saved signature stored in the user's AppData for reuse.
|
||||||
|
/// </summary>
|
||||||
|
/// <summary>Distinguishes a full signature from a (smaller) initials stamp. Default is Signature
|
||||||
|
/// so signatures saved before this field existed still deserialize correctly.</summary>
|
||||||
|
public enum SignatureKind { Signature, Initials }
|
||||||
|
|
||||||
|
public class SavedSignature
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||||
|
public string Name { get; set; } = "Signature";
|
||||||
|
/// <summary>Whether this is a full signature or an initials stamp. Drives which popup section
|
||||||
|
/// it appears in and the default placement scale.</summary>
|
||||||
|
public SignatureKind Kind { get; set; } = SignatureKind.Signature;
|
||||||
|
/// <summary>Pen thickness the signature was drawn with (DIPs at CanvasWidth/Height scale).</summary>
|
||||||
|
public double StrokeWidth { get; set; } = 2.5;
|
||||||
|
public List<List<SerializablePoint>> Strokes { get; set; } = [];
|
||||||
|
public double CanvasWidth { get; set; } = 400;
|
||||||
|
public double CanvasHeight { get; set; } = 150;
|
||||||
|
/// <summary>Base-64 encoded PNG for imported image signatures. Null = drawn strokes.</summary>
|
||||||
|
public string? ImageData { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One link rectangle on a page, in render-dim coordinates.
|
||||||
|
///
|
||||||
|
/// Used by the tiled views (continuous, grid, two-page), where a per-link overlay would swallow
|
||||||
|
/// the click without its own handler ever firing - so clicks and the hover cursor are resolved
|
||||||
|
/// by bounds-testing these rects instead. That makes them the source of truth for links outside
|
||||||
|
/// single-page view.
|
||||||
|
///
|
||||||
|
/// TOP-LEVEL, not nested. Links.cs lives in the viewer control while ContextMenu.cs lives on the
|
||||||
|
/// window and also bounds-tests these rects to build the right-click menu, so neither class can
|
||||||
|
/// own the type.
|
||||||
|
/// </summary>
|
||||||
|
internal readonly record struct LinkInfo(
|
||||||
|
double Cx, double Cy, double Cw, double Ch, object Tag, string Tip, int AnnotIndex);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using Docnet.Core;
|
||||||
|
using Docnet.Core.Models;
|
||||||
|
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ViewModel for a single page thumbnail in the sidebar PageList.
|
||||||
|
/// Thumbnail is loaded lazily on a background thread; the UI binds to
|
||||||
|
/// the <see cref="Thumbnail"/> property and updates via PropertyChanged.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class PageThumbnailVm(int pageIndex, string filePath, int rotation = 0) : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
// Limit concurrent pdfium doc-reader opens to avoid contention
|
||||||
|
private static readonly SemaphoreSlim _loadSem = new(2, 2);
|
||||||
|
|
||||||
|
private BitmapSource? _thumb;
|
||||||
|
private bool _loadRequested;
|
||||||
|
|
||||||
|
public int PageIndex { get; } = pageIndex;
|
||||||
|
public string Label => string.Format(
|
||||||
|
Application.Current?.TryFindResource("Str_PageLabel") as string ?? "Page {0}", PageIndex + 1);
|
||||||
|
|
||||||
|
private readonly string _filePath = filePath;
|
||||||
|
private readonly int _rotation = ((rotation % 360) + 360) % 360; // degrees: 0, 90, 180, 270
|
||||||
|
|
||||||
|
public BitmapSource? Thumbnail => _thumb;
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
/// <summary>Called when the ListBox item becomes visible (via binding getter trigger).</summary>
|
||||||
|
public void RequestLoad()
|
||||||
|
{
|
||||||
|
if (_loadRequested) return;
|
||||||
|
_loadRequested = true;
|
||||||
|
System.Threading.Tasks.Task.Run(LoadAsync);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seed the thumbnail before ItemsSource is set - no dispatch needed because
|
||||||
|
/// no binding exists yet. Used to carry old thumbnails across a RefreshPageList
|
||||||
|
/// call so the list never flashes blank.
|
||||||
|
/// </summary>
|
||||||
|
internal void SetThumbnailDirect(BitmapSource src) => _thumb = src;
|
||||||
|
|
||||||
|
/// <summary>Called by RefreshPageList's bulk background loader.</summary>
|
||||||
|
internal void SetThumbnail(BitmapSource src)
|
||||||
|
{
|
||||||
|
// A background load can finish after the app has begun shutting down (or between
|
||||||
|
// tab switches), when Application.Current is briefly null. The UI is going away in
|
||||||
|
// that case, so just drop the update instead of throwing.
|
||||||
|
var app = Application.Current;
|
||||||
|
if (app == null) return;
|
||||||
|
app.Dispatcher.BeginInvoke(new Action(() =>
|
||||||
|
{
|
||||||
|
_thumb = src;
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Thumbnail)));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async System.Threading.Tasks.Task LoadAsync()
|
||||||
|
{
|
||||||
|
await _loadSem.WaitAsync().ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var src = BuildThumb(_filePath, PageIndex, _rotation);
|
||||||
|
if (src != null) SetThumbnail(src);
|
||||||
|
}
|
||||||
|
catch { /* thumbnail not critical */ }
|
||||||
|
finally { _loadSem.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static BitmapSource? BuildThumb(string filePath, int pageIndex, int rotation = 0)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Render thumbnails at a higher resolution than they're usually shown so the page list
|
||||||
|
// stays crisp when the sidebar is dragged wider (thumbnails scale to the sidebar width).
|
||||||
|
// 288px covers the ~240px the page can show at the widest sidebar; raise for sharper,
|
||||||
|
// lower to save memory (each loaded thumbnail is kept in RAM).
|
||||||
|
using var docReader = DocLib.Instance.GetDocReader(filePath, new PageDimensions(288, 576));
|
||||||
|
using var pr = docReader.GetPageReader(pageIndex);
|
||||||
|
int tw = pr.GetPageWidth();
|
||||||
|
int th = pr.GetPageHeight();
|
||||||
|
var raw = KillerPDF.Services.PdfiumInterop.RenderPageWithAnnotations(filePath, pageIndex, tw, th)
|
||||||
|
?? pr.GetImage(); // #141
|
||||||
|
if (tw <= 0 || th <= 0 || raw == null || raw.Length < tw * th * 4)
|
||||||
|
return null;
|
||||||
|
// Apply in-memory rotation (temp file stores /Rotate=0; _pageRotations holds true angle)
|
||||||
|
if (rotation != 0)
|
||||||
|
(raw, tw, th) = Services.BitmapHelpers.RotateBitmap(raw, tw, th, rotation);
|
||||||
|
return EncodeToBitmapSource(raw, tw, th);
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encode already-decoded BGRA pixels (with rotation applied) to a frozen BitmapFrame.
|
||||||
|
/// Called by the RefreshPageList bulk loader which manages its own doc reader.
|
||||||
|
/// </summary>
|
||||||
|
internal static BitmapSource? BuildThumbFromRaw(byte[] bgra, int width, int height)
|
||||||
|
=> EncodeToBitmapSource(bgra, width, height);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encode raw BGRA (pdfium) → PNG → frozen BitmapFrame entirely on the calling thread.
|
||||||
|
/// GDI+ Format32bppArgb is BGRA in memory, matching pdfium output exactly.
|
||||||
|
/// </summary>
|
||||||
|
private static BitmapSource? EncodeToBitmapSource(byte[] bgra, int width, int height)
|
||||||
|
{
|
||||||
|
var pin = GCHandle.Alloc(bgra, GCHandleType.Pinned);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var bmp = new System.Drawing.Bitmap(
|
||||||
|
width, height, width * 4,
|
||||||
|
System.Drawing.Imaging.PixelFormat.Format32bppArgb,
|
||||||
|
pin.AddrOfPinnedObject());
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
bmp.Save(ms, System.Drawing.Imaging.ImageFormat.Png);
|
||||||
|
ms.Position = 0;
|
||||||
|
var src = BitmapFrame.Create(ms, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
|
||||||
|
src.Freeze();
|
||||||
|
return src;
|
||||||
|
}
|
||||||
|
finally { pin.Free(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.Windows.Media;
|
||||||
|
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
internal enum StampKind { PageNumber, Watermark }
|
||||||
|
|
||||||
|
// The full configuration produced/edited by the Stamp window. One spec can drive page numbers,
|
||||||
|
// a watermark, or both, each over its own page range. A spec is the unit that gets re-opened when
|
||||||
|
// the user double-clicks a placed stamp, so it carries everything needed to recreate the stamps.
|
||||||
|
internal sealed class StampSpec
|
||||||
|
{
|
||||||
|
// ---- Page numbers ----
|
||||||
|
public bool NumbersEnabled;
|
||||||
|
public int StartNumber = 1;
|
||||||
|
public string Format = "{n}"; // {n} = this page's number, {N} = total
|
||||||
|
public int NumPosH = 1; // 0 left, 1 center, 2 right
|
||||||
|
public int NumPosV = 2; // 0 top, 1 middle, 2 bottom
|
||||||
|
public double NumFontPt = 12;
|
||||||
|
public Color NumColor = Colors.Black;
|
||||||
|
public string NumRange = ""; // "" = all pages; else "1-3,5"
|
||||||
|
public bool NumMirror; // flip left/right each page so numbers sit on the outer edge
|
||||||
|
public double NumCustomX = 0.5; // used when NumPosH == -1 (Custom): center as a fraction of page
|
||||||
|
public double NumCustomY = 0.92;
|
||||||
|
|
||||||
|
// ---- Watermark ----
|
||||||
|
public bool WmEnabled;
|
||||||
|
public bool WmIsImage; // false = text, true = image
|
||||||
|
// Localized default (falls back to DRAFT); resolved at model construction on the UI thread.
|
||||||
|
public string WmText = System.Windows.Application.Current?.TryFindResource("Str_Stamp_DefaultText") as string ?? "DRAFT";
|
||||||
|
public string WmFont = "Segoe UI";
|
||||||
|
public double WmFontPt = 64;
|
||||||
|
public Color WmColor = Color.FromRgb(0x88, 0x88, 0x88);
|
||||||
|
public double WmOpacity = 0.25; // 0..1
|
||||||
|
public double WmAngle = 45; // degrees, counter-clockwise
|
||||||
|
public int WmPosH = 1; // 0 left, 1 center, 2 right
|
||||||
|
public int WmPosV = 1; // 0 top, 1 middle, 2 bottom
|
||||||
|
public string? WmImagePath; // source image when WmIsImage
|
||||||
|
public double WmScale = 1.0; // multiplier on the natural placement size
|
||||||
|
public string WmRange = ""; // "" = all pages
|
||||||
|
public double WmCustomX = 0.5; // used when WmPosH == -1 (Custom): center as a fraction of page
|
||||||
|
public double WmCustomY = 0.5;
|
||||||
|
|
||||||
|
public StampSpec Clone() => (StampSpec)MemberwiseClone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single placed stamp on one page. It points back at the spec that created it so a double-click
|
||||||
|
// on the page can re-open the Stamp window with the original settings. The concrete text/position is
|
||||||
|
// derived from Spec + page geometry at render/burn time, so nothing here needs the resolved layout.
|
||||||
|
internal sealed class StampInstance
|
||||||
|
{
|
||||||
|
public int PageIndex;
|
||||||
|
public StampKind Kind;
|
||||||
|
public StampSpec Spec = null!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
// The undo stack's entry type. Each entry is either an annotation removal or a full document
|
||||||
|
// snapshot; AnnotationGroup removes a specific set in one step (a text edit = cover + text).
|
||||||
|
//
|
||||||
|
// TOP-LEVEL, not nested in MainWindow. The code that pushes undo entries - Annotations.cs and
|
||||||
|
// TextEditing.cs - lives in KillerPDF.Controls, where a type nested in MainWindow only spells
|
||||||
|
// as MainWindow.UndoEntry; that would mean qualifying roughly 30 call sites for no gain. As
|
||||||
|
// top-level types in KillerPDF they resolve unqualified from the child namespace too.
|
||||||
|
//
|
||||||
|
// This also retires the CS0052 chain that made them internal in the first place: DocumentSession
|
||||||
|
// had to be internal for the render cache, its UndoStack field is Stack<UndoEntry>, and a field
|
||||||
|
// cannot be more accessible than its type.
|
||||||
|
|
||||||
|
internal enum UndoKind { Annotation, Document, StampBatch, ClearAnnotations, AnnotationGroup, PageSnapshot }
|
||||||
|
|
||||||
|
internal readonly record struct UndoEntry(
|
||||||
|
UndoKind Kind,
|
||||||
|
int PageIdx = -1,
|
||||||
|
byte[]? DocBytes = null,
|
||||||
|
bool WasDirty = false,
|
||||||
|
int[]? Pages = null,
|
||||||
|
PageAnnotation? Annot = null,
|
||||||
|
Dictionary<int, List<PageAnnotation>>? AnnotSnapshot = null,
|
||||||
|
List<PageAnnotation>? AnnotGroup = null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
// How a document view lays its pages out, and how it fits them to the viewport.
|
||||||
|
//
|
||||||
|
// TOP-LEVEL, not nested in MainWindow. The viewer is a UserControl in KillerPDF.Controls, and
|
||||||
|
// from there a type nested in MainWindow only spells as MainWindow.ViewMode - which would mean
|
||||||
|
// qualifying 91 references for no gain. As top-level types in KillerPDF they resolve
|
||||||
|
// unqualified from KillerPDF.Controls too (a namespace declaration puts its parent namespaces
|
||||||
|
// in scope), so every call site compiles untouched.
|
||||||
|
//
|
||||||
|
// internal, not public: nothing outside the assembly has any business with either.
|
||||||
|
|
||||||
|
/// <summary>Page layout for a document view. RenderPage is Single/TwoPage/Grid only and is
|
||||||
|
/// guarded to no-op in Continuous - see the render pipeline's notes on why the two pipelines
|
||||||
|
/// cannot be mixed.</summary>
|
||||||
|
internal enum ViewMode { Single, Continuous, TwoPage, Grid }
|
||||||
|
|
||||||
|
/// <summary>Automatic fit applied on resize, or None when the user has set a zoom.</summary>
|
||||||
|
internal enum FitMode { None, Width, Page }
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
|
||||||
|
namespace KillerPDF
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Everything ONE document view owns. Split pane needs two of these; each PdfViewer control
|
||||||
|
/// owns one, and the window reads the active one back through its `_view` property, so the ~500
|
||||||
|
/// call sites behind the window's forwarding properties are untouched.
|
||||||
|
///
|
||||||
|
/// This is deliberately the PER-VIEW cut, not the per-document one. Per-document state
|
||||||
|
/// (annotations, undo, form values, search hits) already travels in DocumentSession, which
|
||||||
|
/// tab switching swaps by reference - see the comment above _annotations in
|
||||||
|
/// MainWindow.xaml.cs. A second pane needs its own live visual maps and its own view mode
|
||||||
|
/// and zoom; it does NOT need a second copy of the per-document machinery, because each
|
||||||
|
/// pane will simply own its own set of sessions.
|
||||||
|
///
|
||||||
|
/// TOP-LEVEL, not nested in MainWindow. The viewer lives in KillerPDF.Controls and cannot own a
|
||||||
|
/// type nested in the window without every reference spelling out MainWindow.ViewerState.
|
||||||
|
/// ViewMode and FitMode live in Models/ViewTypes.cs for the same reason.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ViewerState
|
||||||
|
{
|
||||||
|
/// <summary>Unified page -> overlay map covering EVERY rendered page, the primary
|
||||||
|
/// included. The single source of truth the canvas accessors read from.</summary>
|
||||||
|
public readonly Dictionary<int, Canvas> Pages = [];
|
||||||
|
|
||||||
|
/// <summary>Per-page overlay canvases for the multi-page tile systems (continuous
|
||||||
|
/// overlays, or grid / two-page secondaries). Holds only secondary tiles and is driven
|
||||||
|
/// by the tile-recycling machinery.</summary>
|
||||||
|
public readonly Dictionary<int, Canvas> ContinuousCanvases = [];
|
||||||
|
|
||||||
|
/// <summary>The page this view is showing (0-based; -1 = no document).
|
||||||
|
///
|
||||||
|
/// This exists because reading the SIDEBAR's selected thumbnail,
|
||||||
|
/// `PageList.SelectedIndex` (118 times across 24 files), works with one pane and cannot
|
||||||
|
/// work with two - there is one sidebar and two current pages, so a viewer inside the
|
||||||
|
/// control has nothing to ask.
|
||||||
|
///
|
||||||
|
/// This is the storage; the sidebar FOLLOWS it. Kept in sync in exactly two places,
|
||||||
|
/// which between them cover every write:
|
||||||
|
/// - PageList_SelectionChanged (PageSelection.cs) mirrors the sidebar back into here,
|
||||||
|
/// unconditionally and before its own >= 0 guard, so clearing the list to -1 (tab
|
||||||
|
/// close, document close) is mirrored too.
|
||||||
|
/// - SyncCurrentPageTo (Viewport.cs), which detaches that handler to avoid re-entry
|
||||||
|
/// and so would otherwise slip past the mirror.
|
||||||
|
/// Everything that sets PageList.SelectedIndex directly still routes through the
|
||||||
|
/// handler, so those need no change.
|
||||||
|
///
|
||||||
|
/// The 118 call sites are deliberately NOT repointed at this field: the render pipeline
|
||||||
|
/// switches over to reading it as it moves into the control.</summary>
|
||||||
|
public int CurrentPage = -1;
|
||||||
|
|
||||||
|
/// <summary>Current view mode for this view.</summary>
|
||||||
|
public ViewMode Mode = ViewMode.Continuous;
|
||||||
|
|
||||||
|
/// <summary>Mode a fade is transitioning to, if one is in flight. Reads that need the
|
||||||
|
/// destination rather than the current mode use `Pending ?? Mode` - the fade takes
|
||||||
|
/// ~90ms and Mode lags behind it, which is what made wheel-cycling need several
|
||||||
|
/// notches before it was fixed.</summary>
|
||||||
|
public ViewMode? Pending;
|
||||||
|
|
||||||
|
// ── Zoom / fit ──────────────────────────────────────────────────────────────────
|
||||||
|
public double ZoomLevel = 1.0;
|
||||||
|
/// <summary>Zoom the current bitmaps were rasterized at, so the re-sharpen pass knows
|
||||||
|
/// whether what is on screen is still crisp enough.</summary>
|
||||||
|
public double LastRenderZoom = 1.0;
|
||||||
|
/// <summary>Primary (spread-left) page currently rasterized.</summary>
|
||||||
|
public int RenderedPrimaryPage = -1;
|
||||||
|
public FitMode Fit = FitMode.None;
|
||||||
|
|
||||||
|
// ── In-flight render work ───────────────────────────────────────────────────────
|
||||||
|
// Each view cancels and reschedules its own rendering, so two panes must not share
|
||||||
|
// these - one pane's mode switch would otherwise cancel the other's render.
|
||||||
|
public System.Windows.Threading.DispatcherTimer? RerenderTimer;
|
||||||
|
public System.Threading.CancellationTokenSource? SecondaryRenderCts;
|
||||||
|
public System.Threading.CancellationTokenSource? ContinuousRenderCts;
|
||||||
|
/// <summary>#85 visible-page re-sharpen.</summary>
|
||||||
|
public System.Threading.CancellationTokenSource? ContinuousSharpenCts;
|
||||||
|
|
||||||
|
// ── Continuous-view bookkeeping ─────────────────────────────────────────────────
|
||||||
|
/// <summary>Slots currently holding a hi-res bitmap.</summary>
|
||||||
|
public readonly HashSet<int> ContinuousSharpPages = [];
|
||||||
|
/// <summary>Budget those slots were sharpened at.</summary>
|
||||||
|
public int ContinuousSharpW;
|
||||||
|
public readonly List<double> ContinuousTops = [];
|
||||||
|
/// <summary>Page to scroll to once its grid tile streams in (-1 = none).</summary>
|
||||||
|
public int GridScrollToPage = -1;
|
||||||
|
/// <summary>Re-scroll here once its true height is known.</summary>
|
||||||
|
public int ContinuousScrollTarget = -1;
|
||||||
|
public double ContinuousPageW;
|
||||||
|
|
||||||
|
// ── Gesture routing ─────────────────────────────────────────────────────────────
|
||||||
|
/// <summary>The page surface a pointer gesture started on, captured on mouse-down.
|
||||||
|
/// Kept separate from the active canvas because RenderAllAnnotations reuses that as its
|
||||||
|
/// render target, and in Grid view tiles stream in asynchronously and re-point it
|
||||||
|
/// mid-gesture - which committed annotations to the wrong page.</summary>
|
||||||
|
public Canvas? GestureCanvas;
|
||||||
|
public int GesturePage = -1;
|
||||||
|
|
||||||
|
// ── Visual hosts ────────────────────────────────────────────────────────────────
|
||||||
|
// References only - the window still creates and owns the actual elements. Today
|
||||||
|
// ContinuousPanel / PageContentPanel / PageContentGrid come from FindName in the
|
||||||
|
// window ctor and are the ONE window's XAML; AnnotationCanvas / PageImage are the
|
||||||
|
// code-built primary tile (Viewport.BuildPrimaryTile) and ActiveCanvas is re-pointed
|
||||||
|
// on mouse-down. Holding them here is what lets the next stage hand each viewer its
|
||||||
|
// own tile tree without touching any of the ~250 call sites that use them.
|
||||||
|
public StackPanel ContinuousPanel = null!;
|
||||||
|
public WrapPanel PageContentPanel = null!;
|
||||||
|
public Grid PageContentGrid = null!;
|
||||||
|
/// <summary>The hardcoded primary tile's overlay, shown in Single/Grid/TwoPage.</summary>
|
||||||
|
public Canvas AnnotationCanvas = null!;
|
||||||
|
public Image PageImage = null!;
|
||||||
|
/// <summary>Active annotation surface. Single view: always AnnotationCanvas.
|
||||||
|
/// Continuous: set on mouse-down to the clicked page's overlay.</summary>
|
||||||
|
public Canvas ActiveCanvas = null!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net48</TargetFramework>
|
||||||
|
<PlatformTarget>x64</PlatformTarget>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>disable</ImplicitUsings>
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
|
<AssemblyName Condition="'$(LauncherAssemblyName)' != ''">$(LauncherAssemblyName)</AssemblyName>
|
||||||
|
<ApplicationIcon Condition="'$(LauncherIcon)' != ''">$(LauncherIcon)</ApplicationIcon>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<Version Condition="'$(LauncherVersion)' != ''">$(LauncherVersion)</Version>
|
||||||
|
<AssemblyVersion Condition="'$(LauncherVersion)' != ''">$(LauncherVersion).0</AssemblyVersion>
|
||||||
|
<FileVersion Condition="'$(LauncherVersion)' != ''">$(LauncherVersion).0</FileVersion>
|
||||||
|
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||||
|
<!-- The launcher only uses .NET Framework inbox assemblies, so it needs no binding redirects.
|
||||||
|
Disabling their generated config also keeps SDK 10 publish from looking for a config named
|
||||||
|
after the overridden release assembly instead of the project assembly. -->
|
||||||
|
<AutoGenerateBindingRedirects>false</AutoGenerateBindingRedirects>
|
||||||
|
<GenerateBindingRedirectsOutputType>false</GenerateBindingRedirectsOutputType>
|
||||||
|
<DefineConstants Condition="'$(AllowUnsignedInstall)' == 'true'">$(DefineConstants);ALLOW_UNSIGNED_INSTALL</DefineConstants>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="System.IO.Compression" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup Condition="Exists('$(PayloadZip)')">
|
||||||
|
<EmbeddedResource Include="$(PayloadZip)" LogicalName="KillerLauncher.payload.zip" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace KillerLauncher
|
||||||
|
{
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
private const string ProductName = "KillerPDF";
|
||||||
|
private const string InnerExeName = "KillerPDF.App.exe";
|
||||||
|
private const string PayloadResourceName = "KillerLauncher.payload.zip";
|
||||||
|
private const string ManifestName = "payload.manifest";
|
||||||
|
private const string PortableMarkerName = ".killerpdf-portable";
|
||||||
|
private const string TestInstallRootEnvironmentVariable = "KILLERPDF_TEST_INSTALL_ROOT";
|
||||||
|
private const string SkipRegistrationEnvironmentVariable = "KILLERPDF_SKIP_REGISTRATION";
|
||||||
|
|
||||||
|
private static readonly string UserInstallDirectory = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", ProductName);
|
||||||
|
private static readonly string MachineInstallDirectory = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), ProductName);
|
||||||
|
private static readonly string PortableRoot = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), ProductName, "Portable");
|
||||||
|
|
||||||
|
[STAThread]
|
||||||
|
private static int Main(string[] args)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (args.Any(a => string.Equals(a, "/install-user", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
return Install(machine: false, desktop: args.Any(a => string.Equals(a, "/desktop", StringComparison.OrdinalIgnoreCase)));
|
||||||
|
|
||||||
|
if (args.Any(a => string.Equals(a, "/silent", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
return Install(machine: true, desktop: false);
|
||||||
|
|
||||||
|
return RunPortable(args);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show(
|
||||||
|
ProductName + " could not start.\n\n" + ex.Message,
|
||||||
|
ProductName,
|
||||||
|
MessageBoxButtons.OK,
|
||||||
|
MessageBoxIcon.Error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int RunPortable(string[] args)
|
||||||
|
{
|
||||||
|
SweepAbandonedPortableDirectories();
|
||||||
|
Directory.CreateDirectory(PortableRoot);
|
||||||
|
|
||||||
|
string version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "unknown";
|
||||||
|
string directory = Path.Combine(PortableRoot,
|
||||||
|
version + "-" + Process.GetCurrentProcess().Id.ToString(CultureInfo.InvariantCulture) + "-" + Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ExtractAndVerify(directory);
|
||||||
|
WritePortableMarker(directory, version, Process.GetCurrentProcess().Id, null);
|
||||||
|
|
||||||
|
var start = new ProcessStartInfo(Path.Combine(directory, InnerExeName), QuoteArguments(args))
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
WorkingDirectory = directory
|
||||||
|
};
|
||||||
|
start.EnvironmentVariables["KILLERPDF_LAUNCHER_PATH"] = CurrentExecutablePath();
|
||||||
|
start.EnvironmentVariables["KILLERPDF_LAUNCHER_PID"] =
|
||||||
|
Process.GetCurrentProcess().Id.ToString(CultureInfo.InvariantCulture);
|
||||||
|
start.EnvironmentVariables["KILLERPDF_PORTABLE_ROOT"] = directory;
|
||||||
|
|
||||||
|
using (var child = Process.Start(start))
|
||||||
|
{
|
||||||
|
if (child == null) throw new InvalidOperationException("The application process could not be created.");
|
||||||
|
WritePortableMarker(directory, version, Process.GetCurrentProcess().Id, child.Id);
|
||||||
|
child.WaitForExit();
|
||||||
|
return child.ExitCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DeleteDirectoryWithRetries(directory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Install(bool machine, bool desktop)
|
||||||
|
{
|
||||||
|
if (!IsTrustedForInstall(CurrentExecutablePath()))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Installation was refused because this download does not have a valid KillerPDF digital signature.");
|
||||||
|
|
||||||
|
string? testRoot = Environment.GetEnvironmentVariable(TestInstallRootEnvironmentVariable);
|
||||||
|
if (string.IsNullOrWhiteSpace(testRoot) && !machine &&
|
||||||
|
(File.Exists(Path.Combine(MachineInstallDirectory, InnerExeName)) ||
|
||||||
|
File.Exists(Path.Combine(MachineInstallDirectory, "KillerPDF.exe"))))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"KillerPDF is already installed for everyone on this computer. Update that installation, " +
|
||||||
|
"or uninstall it before choosing a per-user install. KillerPDF will not create two installed copies.");
|
||||||
|
|
||||||
|
string destination = !string.IsNullOrWhiteSpace(testRoot)
|
||||||
|
? Path.GetFullPath(testRoot)
|
||||||
|
: (machine ? MachineInstallDirectory : UserInstallDirectory);
|
||||||
|
string parent = Path.GetDirectoryName(destination) ?? throw new InvalidOperationException("Invalid install directory.");
|
||||||
|
Directory.CreateDirectory(parent);
|
||||||
|
|
||||||
|
string staging = destination + ".staging-" + Guid.NewGuid().ToString("N");
|
||||||
|
string backup = destination + ".previous-" + Guid.NewGuid().ToString("N");
|
||||||
|
bool movedExisting = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ExtractAndVerify(staging);
|
||||||
|
|
||||||
|
if (Directory.Exists(destination))
|
||||||
|
{
|
||||||
|
Directory.Move(destination, backup);
|
||||||
|
movedExisting = true;
|
||||||
|
}
|
||||||
|
Directory.Move(staging, destination);
|
||||||
|
|
||||||
|
int registrationExit = string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable(SkipRegistrationEnvironmentVariable), "1", StringComparison.Ordinal)
|
||||||
|
? 0
|
||||||
|
: RunRegistration(destination, machine, desktop);
|
||||||
|
if (registrationExit != 0)
|
||||||
|
throw new InvalidOperationException("Windows integration could not be registered (exit " + registrationExit + ").");
|
||||||
|
|
||||||
|
// A machine-wide install supersedes the current account's per-user copy. This
|
||||||
|
// also covers unattended /silent installs that do not return through the portable
|
||||||
|
// app's InstallAndRelaunch cleanup path.
|
||||||
|
if (machine && string.IsNullOrWhiteSpace(testRoot))
|
||||||
|
RunMaintenance(destination, "/remove-user-install");
|
||||||
|
|
||||||
|
if (movedExisting) DeleteDirectoryWithRetries(backup);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
DeleteDirectoryWithRetries(staging);
|
||||||
|
if (movedExisting && Directory.Exists(backup))
|
||||||
|
{
|
||||||
|
DeleteDirectoryWithRetries(destination);
|
||||||
|
if (!Directory.Exists(destination)) Directory.Move(backup, destination);
|
||||||
|
}
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int RunRegistration(string directory, bool machine, bool desktop)
|
||||||
|
{
|
||||||
|
var arguments = new List<string> { machine ? "/register-machine" : "/register-user" };
|
||||||
|
if (desktop) arguments.Add("/desktop");
|
||||||
|
var start = new ProcessStartInfo(Path.Combine(directory, InnerExeName), QuoteArguments(arguments.ToArray()))
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
WorkingDirectory = directory
|
||||||
|
};
|
||||||
|
using (var process = Process.Start(start))
|
||||||
|
{
|
||||||
|
if (process == null) return 1;
|
||||||
|
process.WaitForExit();
|
||||||
|
return process.ExitCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int RunMaintenance(string directory, string argument)
|
||||||
|
{
|
||||||
|
var start = new ProcessStartInfo(Path.Combine(directory, InnerExeName), argument)
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
WorkingDirectory = directory
|
||||||
|
};
|
||||||
|
using (var process = Process.Start(start))
|
||||||
|
{
|
||||||
|
if (process == null) return 1;
|
||||||
|
process.WaitForExit();
|
||||||
|
return process.ExitCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ExtractAndVerify(string destination)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(destination);
|
||||||
|
using (var payload = Assembly.GetExecutingAssembly().GetManifestResourceStream(PayloadResourceName))
|
||||||
|
{
|
||||||
|
if (payload == null) throw new InvalidOperationException("The application payload is missing.");
|
||||||
|
using (var archive = new ZipArchive(payload, ZipArchiveMode.Read, leaveOpen: false))
|
||||||
|
{
|
||||||
|
var manifestEntry = archive.GetEntry(ManifestName)
|
||||||
|
?? throw new InvalidDataException("The payload manifest is missing.");
|
||||||
|
Dictionary<string, ManifestFile> manifest;
|
||||||
|
using (var reader = new StreamReader(manifestEntry.Open(), Encoding.UTF8, true))
|
||||||
|
manifest = ReadManifest(reader);
|
||||||
|
|
||||||
|
var payloadEntries = archive.Entries
|
||||||
|
.Where(e => !string.IsNullOrEmpty(e.Name) && !string.Equals(e.FullName, ManifestName, StringComparison.Ordinal))
|
||||||
|
.ToDictionary(e => NormalizeRelativePath(e.FullName), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (payloadEntries.Count != manifest.Count || manifest.Keys.Any(k => !payloadEntries.ContainsKey(k)))
|
||||||
|
throw new InvalidDataException("The payload contents do not match its manifest.");
|
||||||
|
|
||||||
|
string destinationRoot = EnsureTrailingSeparator(Path.GetFullPath(destination));
|
||||||
|
foreach (var item in manifest.OrderBy(p => p.Key, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
string outputPath = Path.GetFullPath(Path.Combine(destination, item.Key.Replace('/', Path.DirectorySeparatorChar)));
|
||||||
|
if (!outputPath.StartsWith(destinationRoot, StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new InvalidDataException("The payload contains an unsafe path.");
|
||||||
|
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? destination);
|
||||||
|
using (var input = payloadEntries[item.Key].Open())
|
||||||
|
using (var output = new FileStream(outputPath, FileMode.CreateNew, FileAccess.Write, FileShare.None))
|
||||||
|
input.CopyTo(output);
|
||||||
|
|
||||||
|
var info = new FileInfo(outputPath);
|
||||||
|
if (info.Length != item.Value.Size || !string.Equals(HashFile(outputPath), item.Value.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new InvalidDataException("Payload verification failed for " + item.Key + ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
File.WriteAllLines(Path.Combine(destination, ManifestName),
|
||||||
|
manifest.OrderBy(p => p.Key, StringComparer.Ordinal)
|
||||||
|
.Select(p => p.Value.Sha256 + "\t" +
|
||||||
|
p.Value.Size.ToString(CultureInfo.InvariantCulture) + "\t" + p.Key),
|
||||||
|
new UTF8Encoding(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(Path.Combine(destination, InnerExeName)))
|
||||||
|
throw new InvalidDataException("The payload does not contain " + InnerExeName + ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, ManifestFile> ReadManifest(TextReader reader)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, ManifestFile>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
string? line;
|
||||||
|
while ((line = reader.ReadLine()) != null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#", StringComparison.Ordinal)) continue;
|
||||||
|
var parts = line.Split(new[] { '\t' }, 3);
|
||||||
|
if (parts.Length != 3 || parts[0].Length != 64 || !long.TryParse(parts[1], NumberStyles.None,
|
||||||
|
CultureInfo.InvariantCulture, out long size) || size < 0)
|
||||||
|
throw new InvalidDataException("The payload manifest is invalid.");
|
||||||
|
string path = NormalizeRelativePath(parts[2]);
|
||||||
|
if (result.ContainsKey(path))
|
||||||
|
throw new InvalidDataException("The payload manifest contains a duplicate path.");
|
||||||
|
result.Add(path, new ManifestFile(parts[0], size));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeRelativePath(string path)
|
||||||
|
{
|
||||||
|
string normalized = path.Replace('\\', '/').TrimStart('/');
|
||||||
|
if (string.IsNullOrWhiteSpace(normalized) || Path.IsPathRooted(path) || normalized.Contains(":") ||
|
||||||
|
normalized.Split('/').Any(p => p.Length == 0 || p == "." || p == ".."))
|
||||||
|
throw new InvalidDataException("The payload contains an unsafe path: " + path);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SweepAbandonedPortableDirectories()
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(PortableRoot)) return;
|
||||||
|
foreach (string directory in Directory.GetDirectories(PortableRoot))
|
||||||
|
{
|
||||||
|
string marker = Path.Combine(directory, PortableMarkerName);
|
||||||
|
if (!File.Exists(marker)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lines = File.ReadAllLines(marker);
|
||||||
|
if (lines.Length > 0 && string.Equals(lines[0], ProductName, StringComparison.Ordinal) &&
|
||||||
|
!MarkerHasLiveProcess(lines))
|
||||||
|
DeleteDirectoryWithRetries(directory);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WritePortableMarker(string directory, string version, int launcherPid, int? childPid)
|
||||||
|
{
|
||||||
|
File.WriteAllLines(Path.Combine(directory, PortableMarkerName), new[]
|
||||||
|
{
|
||||||
|
ProductName,
|
||||||
|
version,
|
||||||
|
launcherPid.ToString(CultureInfo.InvariantCulture),
|
||||||
|
childPid?.ToString(CultureInfo.InvariantCulture) ?? string.Empty
|
||||||
|
}, new UTF8Encoding(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MarkerHasLiveProcess(string[] lines)
|
||||||
|
{
|
||||||
|
foreach (string text in lines.Skip(2).Take(2))
|
||||||
|
{
|
||||||
|
if (!int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out int pid)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var process = Process.GetProcessById(pid))
|
||||||
|
if (!process.HasExited) return true;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DeleteDirectoryWithRetries(string directory)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(directory)) return;
|
||||||
|
for (int attempt = 0; attempt < 5; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (string file in Directory.GetFiles(directory, "*", SearchOption.AllDirectories))
|
||||||
|
try { File.SetAttributes(file, FileAttributes.Normal); } catch { }
|
||||||
|
Directory.Delete(directory, recursive: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch when (attempt < 4)
|
||||||
|
{
|
||||||
|
System.Threading.Thread.Sleep(150 * (attempt + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTrustedForInstall(string path)
|
||||||
|
{
|
||||||
|
#if ALLOW_UNSIGNED_INSTALL
|
||||||
|
// Local packages produced by build-portable.ps1 are intentionally unsigned and must
|
||||||
|
// remain installable for end-to-end testing. release.ps1 omits this compile-time flag;
|
||||||
|
// its public launcher has no environment-variable or command-line bypass.
|
||||||
|
return true;
|
||||||
|
#else
|
||||||
|
return AuthenticodeTrust.IsValid(path);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HashFile(string path)
|
||||||
|
{
|
||||||
|
using (var stream = File.OpenRead(path))
|
||||||
|
using (var sha = SHA256.Create())
|
||||||
|
return BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CurrentExecutablePath() => Process.GetCurrentProcess().MainModule?.FileName
|
||||||
|
?? Assembly.GetExecutingAssembly().Location;
|
||||||
|
|
||||||
|
private static string EnsureTrailingSeparator(string path) =>
|
||||||
|
path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||||||
|
? path
|
||||||
|
: path + Path.DirectorySeparatorChar;
|
||||||
|
|
||||||
|
private static string QuoteArguments(IEnumerable<string> arguments) =>
|
||||||
|
string.Join(" ", arguments.Select(QuoteArgument));
|
||||||
|
|
||||||
|
private static string QuoteArgument(string argument)
|
||||||
|
{
|
||||||
|
if (argument.Length > 0 && argument.All(c => !char.IsWhiteSpace(c) && c != '"')) return argument;
|
||||||
|
var sb = new StringBuilder("\"");
|
||||||
|
int slashes = 0;
|
||||||
|
foreach (char c in argument)
|
||||||
|
{
|
||||||
|
if (c == '\\') { slashes++; continue; }
|
||||||
|
if (c == '"') sb.Append('\\', slashes * 2 + 1).Append('"');
|
||||||
|
else { sb.Append('\\', slashes).Append(c); }
|
||||||
|
slashes = 0;
|
||||||
|
}
|
||||||
|
sb.Append('\\', slashes * 2).Append('"');
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ManifestFile
|
||||||
|
{
|
||||||
|
internal ManifestFile(string sha256, long size) { Sha256 = sha256; Size = size; }
|
||||||
|
internal string Sha256 { get; }
|
||||||
|
internal long Size { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class AuthenticodeTrust
|
||||||
|
{
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct WinTrustFileInfo
|
||||||
|
{
|
||||||
|
internal uint Size;
|
||||||
|
internal IntPtr FilePath;
|
||||||
|
internal IntPtr File;
|
||||||
|
internal IntPtr KnownSubject;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct WinTrustData
|
||||||
|
{
|
||||||
|
internal uint Size;
|
||||||
|
internal IntPtr PolicyCallbackData;
|
||||||
|
internal IntPtr SipClientData;
|
||||||
|
internal uint UiChoice;
|
||||||
|
internal uint RevocationChecks;
|
||||||
|
internal uint UnionChoice;
|
||||||
|
internal IntPtr Union;
|
||||||
|
internal uint StateAction;
|
||||||
|
internal IntPtr StateData;
|
||||||
|
internal IntPtr UrlReference;
|
||||||
|
internal uint ProviderFlags;
|
||||||
|
internal uint UiContext;
|
||||||
|
internal IntPtr SignatureSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Guid VerifyGeneric = new Guid("00AAC56B-CD44-11d0-8CC2-00C04FC295EE");
|
||||||
|
|
||||||
|
[DllImport("wintrust.dll", ExactSpelling = true, CharSet = CharSet.Unicode)]
|
||||||
|
private static extern uint WinVerifyTrust(IntPtr window, ref Guid action, IntPtr trustData);
|
||||||
|
|
||||||
|
internal static bool IsValid(string path)
|
||||||
|
{
|
||||||
|
IntPtr pathPointer = Marshal.StringToHGlobalUni(path);
|
||||||
|
IntPtr filePointer = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(WinTrustFileInfo)));
|
||||||
|
IntPtr dataPointer = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(WinTrustData)));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Marshal.StructureToPtr(new WinTrustFileInfo
|
||||||
|
{
|
||||||
|
Size = (uint)Marshal.SizeOf(typeof(WinTrustFileInfo)),
|
||||||
|
FilePath = pathPointer
|
||||||
|
}, filePointer, false);
|
||||||
|
Marshal.StructureToPtr(new WinTrustData
|
||||||
|
{
|
||||||
|
Size = (uint)Marshal.SizeOf(typeof(WinTrustData)),
|
||||||
|
UiChoice = 2,
|
||||||
|
UnionChoice = 1,
|
||||||
|
Union = filePointer,
|
||||||
|
ProviderFlags = 0x1000
|
||||||
|
}, dataPointer, false);
|
||||||
|
var action = VerifyGeneric;
|
||||||
|
return WinVerifyTrust(IntPtr.Zero, ref action, dataPointer) == 0;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Marshal.FreeHGlobal(dataPointer);
|
||||||
|
Marshal.FreeHGlobal(filePointer);
|
||||||
|
Marshal.FreeHGlobal(pathPointer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="KillerLauncher.app"/>
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||||
|
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<Configuration>Release</Configuration>
|
||||||
|
<Platform>Any CPU</Platform>
|
||||||
|
<PublishDir>bin\Release\net8.0-windows\publish\win-x64\</PublishDir>
|
||||||
|
<PublishProtocol>FileSystem</PublishProtocol>
|
||||||
|
<_TargetId>Folder</_TargetId>
|
||||||
|
<TargetFramework>net48</TargetFramework>
|
||||||
|
<RuntimeIdentifier>win-x86</RuntimeIdentifier>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<Configuration>Release</Configuration>
|
||||||
|
<Platform>Any CPU</Platform>
|
||||||
|
<PublishDir>bin\Release\net48\publish\</PublishDir>
|
||||||
|
<PublishProtocol>FileSystem</PublishProtocol>
|
||||||
|
<_TargetId>Folder</_TargetId>
|
||||||
|
<TargetFramework>net48</TargetFramework>
|
||||||
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user