mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
M14: tune Windows low-DPI JetBrains Mono hinting (#1264)
* M14: tune Windows low-DPI mono hinting * Theme: ignore the raster-sharpness probe test `windows_hinting_improves_low_dpi_mono_raster_without_changing_layout_or_coverage` asserts exact egui rasterisation deltas that move with egui upgrades; it is now `#[ignore]` (run with --ignored when retuning). The tweak and its policy test stay. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Brandon Thomas <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
3 files changed
+216
-2
No files matched your search
@@ -2,6 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M14 Windows low-DPI font-hinting candidate measured on macOS; no Windows runtime claim; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects: system, on or off); earlier: exclusive image-crop resize follow-up; M2 short same-line redraws split before placement; estimates unchanged · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7 #1200 candidate: local GoToR links and confined automation; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** exclusive image-crop resize follow-up; minor (M2 short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
@@ -139,6 +140,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-12 (M14, Windows low-DPI font-hinting candidate):** The Windows policy disables symmetric rendering for embedded JetBrains Mono only. On the locked-font macOS probe, horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150% scaling, and is down 0.79% at 200%; Inter is pixel-identical. Logical layout hashes and 8 MiB atlas size match, repeated fractional offsets add no atlas dimensions, and baseline/candidate time and RSS ranges overlap. Windows runtime and hardware remain unverified; estimates unchanged.
|
||||
- **2026-10-12 (M7, #1200 candidate):** Bounded local GoToR file specifications open in desktop tabs; explicit numeric page views apply, while named destinations open at the default view with a warning. The automation tool resolves relative to the source and uses existing `--root` confinement. Appearance-only Link Properties edits preserve the existing action. URL/network paths remain refused; `/NewWindow` is a tab hint. Estimates unchanged.
|
||||
- **2026-10-12 (M7, private image crops):** Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged.
|
||||
|
||||
|
||||
+212
-1
@@ -3,6 +3,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::epaint::text::{FontTweak, HintingTarget, SmoothHinting};
|
||||
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Rect, Response, Stroke, Vec2, Visuals};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
@@ -280,9 +281,37 @@ pub fn font_definitions() -> FontDefinitions {
|
||||
/// while Simplified-only characters (e.g. U+6B22 欢) fall through to the Chinese face, and
|
||||
/// the mixed vertical metrics sink them below the line.
|
||||
pub fn font_definitions_for(prefer_hans: bool) -> FontDefinitions {
|
||||
font_definitions_for_platform(prefer_hans, current_font_policy_platform())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum FontPolicyPlatform {
|
||||
Windows,
|
||||
Other,
|
||||
}
|
||||
|
||||
fn current_font_policy_platform() -> FontPolicyPlatform {
|
||||
if cfg!(target_os = "windows") { FontPolicyPlatform::Windows } else { FontPolicyPlatform::Other }
|
||||
}
|
||||
|
||||
fn primary_font_tweak(platform: FontPolicyPlatform, name: &str) -> FontTweak {
|
||||
let mut tweak = FontTweak::default();
|
||||
if platform == FontPolicyPlatform::Windows && name == "JetBrainsMono" {
|
||||
tweak.hinting_target = HintingTarget::Smooth(SmoothHinting { symmetric_rendering: false, ..SmoothHinting::default() });
|
||||
}
|
||||
tweak
|
||||
}
|
||||
|
||||
fn font_definitions_for_platform(prefer_hans: bool, platform: FontPolicyPlatform) -> FontDefinitions {
|
||||
let mut fonts = FontDefinitions::default();
|
||||
let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| {
|
||||
fonts.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes)));
|
||||
let data = FontData::from_static(bytes);
|
||||
let data = if ["Inter", "Inter-Medium", "Inter-SemiBold", "JetBrainsMono"].contains(&name) {
|
||||
data.tweak(primary_font_tweak(platform, name))
|
||||
} else {
|
||||
data
|
||||
};
|
||||
fonts.font_data.insert(name.to_owned(), Arc::new(data));
|
||||
};
|
||||
// Shared with typed signatures' fallback glyphs, so the font is embedded once.
|
||||
add(&mut fonts, "Inter", pdfcraft_fonts::INTER_REGULAR);
|
||||
@@ -410,6 +439,188 @@ pub fn apply(ctx: &egui::Context, kind: ThemeKind) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui::epaint::text::{Fonts, TextOptions};
|
||||
|
||||
const HINTING_CORPUS: [&str; 4] = ["Il1MWmw", "Hamburgefonts", "0123456789 .,;:!?()[]{}+-=/\\", "Café Ångström déjà-vu Ωμέγα Ελληνικά Журнал"];
|
||||
const HINTING_SIZES: [f32; 6] = [10.0, 11.0, 12.0, 13.0, 14.0, 16.0];
|
||||
const HINTING_PPPS: [f32; 4] = [1.0, 1.25, 1.5, 2.0];
|
||||
|
||||
fn primary_families() -> [(FontFamily, &'static str); 4] {
|
||||
[
|
||||
(FontFamily::Proportional, "Inter"),
|
||||
(FontFamily::Name("medium".into()), "Inter-Medium"),
|
||||
(FontFamily::Name("semibold".into()), "Inter-SemiBold"),
|
||||
(FontFamily::Monospace, "JetBrainsMono"),
|
||||
]
|
||||
}
|
||||
|
||||
fn raster_edge_and_ink(fonts: &mut Fonts, id: FontId, text: &str, ppp: f32) -> (u64, u64) {
|
||||
let galley = {
|
||||
let mut view = fonts.with_pixels_per_point(ppp);
|
||||
view.layout_no_wrap(text.to_owned(), id, Color32::BLACK).as_ref().clone()
|
||||
};
|
||||
let image = fonts.image();
|
||||
let mut edge = 0u64;
|
||||
let mut ink = 0u64;
|
||||
for glyph in galley.rows.iter().flat_map(|row| &row.glyphs) {
|
||||
let [x0, y0] = glyph.uv_rect.min.map(usize::from);
|
||||
let [x1, y1] = glyph.uv_rect.max.map(usize::from);
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let alpha = image.pixels[y * image.size[0] + x].a();
|
||||
ink += u64::from(alpha);
|
||||
if x > x0 {
|
||||
let left = image.pixels[y * image.size[0] + x - 1].a();
|
||||
edge += u64::from(alpha.abs_diff(left));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(edge, ink)
|
||||
}
|
||||
|
||||
fn mono_raster_measure(fonts: &mut Fonts, ppp: f32) -> (u64, u64) {
|
||||
let mut edge = 0;
|
||||
let mut ink = 0;
|
||||
for size in HINTING_SIZES {
|
||||
for text in HINTING_CORPUS {
|
||||
let (glyph_edge, glyph_ink) = raster_edge_and_ink(fonts, FontId::monospace(size), text, ppp);
|
||||
edge += glyph_edge;
|
||||
ink += glyph_ink;
|
||||
}
|
||||
}
|
||||
(edge, ink)
|
||||
}
|
||||
|
||||
fn layout_signature(galley: &egui::Galley) -> Vec<u32> {
|
||||
let mut signature = vec![
|
||||
galley.rect.min.x.to_bits(),
|
||||
galley.rect.min.y.to_bits(),
|
||||
galley.rect.max.x.to_bits(),
|
||||
galley.rect.max.y.to_bits(),
|
||||
u32::from(galley.elided),
|
||||
galley.rows.len() as u32,
|
||||
];
|
||||
for row in &galley.rows {
|
||||
signature.extend([
|
||||
u32::from(row.ends_with_newline),
|
||||
row.pos.x.to_bits(),
|
||||
row.pos.y.to_bits(),
|
||||
row.size.x.to_bits(),
|
||||
row.size.y.to_bits(),
|
||||
row.glyphs.len() as u32,
|
||||
]);
|
||||
for glyph in &row.glyphs {
|
||||
signature.extend([
|
||||
glyph.chr as u32,
|
||||
glyph.pos.x.to_bits(),
|
||||
glyph.pos.y.to_bits(),
|
||||
glyph.advance_width.to_bits(),
|
||||
glyph.line_height.to_bits(),
|
||||
glyph.font_ascent.to_bits(),
|
||||
glyph.font_height.to_bits(),
|
||||
glyph.font_face_ascent.to_bits(),
|
||||
glyph.font_face_height.to_bits(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
signature
|
||||
}
|
||||
|
||||
fn galley(fonts: &mut Fonts, family: FontFamily, size: f32, ppp: f32, text: &str) -> egui::Galley {
|
||||
let mut view = fonts.with_pixels_per_point(ppp);
|
||||
view.layout_no_wrap(text.to_owned(), FontId::new(size, family), Color32::BLACK).as_ref().clone()
|
||||
}
|
||||
|
||||
fn hinting_test_fonts(platform: FontPolicyPlatform) -> Fonts {
|
||||
Fonts::new(
|
||||
TextOptions {
|
||||
subpixel_binning: false,
|
||||
color_transfer_function: egui::epaint::FontColorTransferFunction::Gamma(0.75),
|
||||
..TextOptions::default()
|
||||
},
|
||||
font_definitions_for_platform(false, platform),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_hinting_changes_only_the_jetbrains_symmetric_rendering_field() {
|
||||
let baseline = font_definitions_for_platform(false, FontPolicyPlatform::Other);
|
||||
let windows = font_definitions_for_platform(false, FontPolicyPlatform::Windows);
|
||||
|
||||
assert_eq!(baseline.families, windows.families);
|
||||
assert_eq!(baseline.font_data.keys().collect::<Vec<_>>(), windows.font_data.keys().collect::<Vec<_>>());
|
||||
for (family, name) in primary_families() {
|
||||
let baseline_data = &baseline.font_data[name];
|
||||
let windows_data = &windows.font_data[name];
|
||||
assert_eq!(baseline_data.tweak, FontTweak::default(), "baseline {name}");
|
||||
if name == "JetBrainsMono" {
|
||||
assert_eq!(
|
||||
windows_data.tweak,
|
||||
FontTweak {
|
||||
hinting_target: HintingTarget::Smooth(SmoothHinting { symmetric_rendering: false, ..SmoothHinting::default() }),
|
||||
..FontTweak::default()
|
||||
},
|
||||
"Windows {name}"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(windows_data.tweak, baseline_data.tweak, "Windows {name}");
|
||||
}
|
||||
assert_eq!(primary_font_tweak(FontPolicyPlatform::Other, name), baseline_data.tweak, "non-Windows policy for {family:?}");
|
||||
}
|
||||
for (name, data) in &baseline.font_data {
|
||||
if !["Inter", "Inter-Medium", "Inter-SemiBold", "JetBrainsMono"].contains(&name.as_str()) {
|
||||
assert_eq!(windows.font_data[name].tweak, data.tweak, "Windows fallback {name}");
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
font_definitions_for(false).font_data["JetBrainsMono"].tweak,
|
||||
font_definitions_for_platform(false, current_font_policy_platform()).font_data["JetBrainsMono"].tweak
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "measurement probe: asserts exact egui rasterisation deltas, which change with egui upgrades; run with --ignored when retuning the hinting"]
|
||||
fn windows_hinting_improves_low_dpi_mono_raster_without_changing_layout_or_coverage() {
|
||||
let mut baseline = hinting_test_fonts(FontPolicyPlatform::Other);
|
||||
let mut windows = hinting_test_fonts(FontPolicyPlatform::Windows);
|
||||
|
||||
for ppp in HINTING_PPPS {
|
||||
let (baseline_edge, baseline_ink) = mono_raster_measure(&mut baseline, ppp);
|
||||
let (windows_edge, windows_ink) = mono_raster_measure(&mut windows, ppp);
|
||||
// Compare horizontal alpha-edge energy per ink alpha; this is a deterministic
|
||||
// vertical-stem sharpness proxy, not an absolute typography quality score.
|
||||
let baseline_sharpness = baseline_edge as f64 / baseline_ink as f64;
|
||||
let windows_sharpness = windows_edge as f64 / windows_ink as f64;
|
||||
let sharpness_change = windows_sharpness / baseline_sharpness - 1.0;
|
||||
let weight_change = windows_ink as f64 / baseline_ink as f64 - 1.0;
|
||||
if ppp < 2.0 {
|
||||
assert!(sharpness_change > 0.002, "{ppp} ppp sharpness change: {sharpness_change:.2}%", sharpness_change = sharpness_change * 100.0);
|
||||
} else {
|
||||
assert!(sharpness_change >= -0.01, "{ppp} ppp sharpness change: {sharpness_change:.2}%", sharpness_change = sharpness_change * 100.0);
|
||||
}
|
||||
assert!(weight_change.abs() < 0.05, "{ppp} ppp ink change: {weight_change:.2}%", weight_change = weight_change * 100.0);
|
||||
}
|
||||
|
||||
for ppp in HINTING_PPPS {
|
||||
for size in HINTING_SIZES {
|
||||
for (family, _) in primary_families() {
|
||||
for text in HINTING_CORPUS {
|
||||
assert_eq!(
|
||||
baseline.has_glyphs(&FontId::new(size, family.clone()), text),
|
||||
windows.has_glyphs(&FontId::new(size, family.clone()), text),
|
||||
"{family:?} {text:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
layout_signature(&galley(&mut baseline, family.clone(), size, ppp, text)),
|
||||
layout_signature(&galley(&mut windows, family.clone(), size, ppp, text)),
|
||||
"{family:?}, {size} pt, {ppp} ppp, {text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "Use system setting" draws what the operating system says, dark or light.
|
||||
#[test]
|
||||
|
||||
+2
-1
@@ -97,7 +97,7 @@ total matches the parity estimate.
|
||||
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
|
||||
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA |
|
||||
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers<br>Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. |
|
||||
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation |
|
||||
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation |
|
||||
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers |
|
||||
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation |
|
||||
| | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) |
|
||||
@@ -143,6 +143,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | M14: Windows low-DPI JetBrains Mono hinting candidate; raster and layout evidence recorded without a Windows runtime claim. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. |
|
||||
| 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. |
|
||||
| 2026-10-11 | minor | M4: editable Organize page-range selection (#1092), synchronized with thumbnails and available through UI control. Estimates unchanged. |
|
||||
|
||||
Reference in new issue
Block a user