diff --git a/.agents/skills/repo-conventions/SKILL.md b/.agents/skills/repo-conventions/SKILL.md index cea0dc7fc..394cca293 100644 --- a/.agents/skills/repo-conventions/SKILL.md +++ b/.agents/skills/repo-conventions/SKILL.md @@ -139,7 +139,7 @@ Subsystem conventions and invariants for KMReader. `AGENTS.md` holds repo-wide r - Runtime GRDB migrations in `LocalDatabase` are immutable once committed: never mutate an already-registered migration (e.g. `create_runtime_schema_v1`, `00002_add_protected_server_flag`) or its helpers; they are the frozen baseline. Any table shape change is a new numbered migration after the latest one; fresh installs run baseline + all later migrations in order. - Migration bodies and schema helpers belong only in `LocalDatabase.swift`; record models and feature stores must not define migration methods or table schemas. New migrations use migration-local literal table/column names rather than current model symbols so later model changes cannot rewrite migration history; do not rewrite frozen migrations solely to retrofit this rule. - New persisted field: update the record model and `CodingKeys`, then add a migration backfilling a safe default. Validate both upgrade and fresh-install paths. -- Manga translations are isolated per Komga instance: `MangaTranslationPageKey` includes the instance id, source/geometry/provider/model signatures, and rows use `(instance_id, cache_key)` as the primary key. Each save prunes same-page rows whose model signature retired (legacy empty signature, or the same archive on an older inference version); other archives and same-signature language/provider/geometry variants survive, so stale entries converge lazily as pages are revisited. Each attempt has an owner; cancelled or superseded attempts must not update reader state or overwrite newer cached results. The format-version-2 model catalog and matching package manifests declare capabilities and per-component engine/version/files/labels; detector and recognizer are separate adapters, unknown engines fail before download, and manga translation consumes only supported `manga_ocr` archives. Two detector engines exist: `rt_detr_v2` (text-tight boxes, the label table drives recognize/orientation) and `yolo11n_seg` (single `bubble` label, whole-bubble boxes; the CoreML export drops the mask-proto branch because regions carry boxes only, confidence arrives sigmoid-squashed in-graph, input is the same stretched 640×640). Two recognizer engines share one CoreML I/O contract and the `MangaGreedyDecoder` loop, differing only in preprocessing selected per engine via `MangaOCRPreprocessing`: `manga_ocr_transformer` (stretch to 224×224, [-1,1]) and `manga_ocr_mobile` (letterbox to 224×224 on white, [0,1]); the letterbox path resamples with a hand-rolled bilinear filter matching the training-time coordinate mapping — CoreGraphics scaling shifts pixel centers and flips glyphs. Detection and recognition models are selected independently per role (`MangaModelRole`, storage keys `manga_ocr.detector`/`.recognizer`, the legacy combined key serves as fallback until the next select, and is dropped once both roles have explicit picks — downgrading past the role keys then finds no selection, though downloads survive and re-selecting fixes it): an archive stays the distribution unit, but the two roles may come from different archives, the one-tap select writes only the roles the archive supports, the management sheet offers each role only role-capable archives (`MangaModelSet.supports(_:for:)`, which deliberately tolerates unknown extra components so future components do not break this build's downloads), and the translation service keeps one prepared `MangaModelSet` per selected archive (two at most, shared when both roles pick the same archive). The model signature is `::`, so cache pruning keys on the detector prefix and legacy single-archive signatures retire through the same prefix. The catalog ships only role-split archives (capabilities `detection` or `ocr` with a single component); the legacy combined archives were withdrawn from the catalog, and already-downloaded ones keep working because the downloaded-model registry is independent of the catalog. Model rows show the archive's capability as chips (`MangaModelCapabilityChips`, derived from its components) alongside the catalog comment. Bundles are downloaded from R2 with byte-level progress feeding the management row's progress bar, SHA-256 verified, and stored under `Application Support/Models` in per-purpose, per-archive namespaces excluded from device backups; hashing and TAR extraction stream off MainActor, the downloaded-model registry is independent of the current catalog, and models never ship in the app bundle. Translation uses a selected OpenAI-compatible provider when configured, otherwise the on-device system provider on iOS 26+/macOS 26+; older versions report missing configuration before OCR instead of doing wasted recognition. Provider configurations, including API keys, live in GRDB. OpenAI-compatible translation and model-list requests share a 30-second hard timeout in `MangaProviderRequest`. Source language prefers series metadata before the default, target language defaults to the system language, comic metadata is optional in the LLM prompt, and overlay geometry is clamped to the displayed page bounds; bubble backgrounds are feathered-edged blobs with an opaque center, tinted with the reader's sepia background; the shape is the user's `bubbleShape` preference (`ellipse` default, `roundedRect`, a rendering-only preference that takes effect through the settings-dismiss page refresh) and the sizing follows the detector's box semantics — each page stores the detector's `MangaTextBoxKind` (`text` for text-tight rt_detr boxes, `bubble` for whole-bubble yolo boxes; legacy cached rows decode as `text`, whose outgrown ellipse keeps corner text covered for either kind) and `MangaBubbleBackground` derives the background and text frames from the shape/kind pair: ellipses on text-tight boxes outgrow ×1.5 so the corners stay covered (√2 minimum), ellipses on whole-bubble boxes stay inscribed with the text area shrunk to 0.5 of the box so glyphs stay on the opaque core with a manga-like white margin, and rounded rectangles hug the box for either kind with the small-inset text area. Detector labels without orientation metadata fall back to physical box geometry (taller than wide, compared in display pixels — normalized heights on portrait pages hide near-square vertical bubbles), and each `MangaPageTranslation` stores its `targetLanguage` (legacy rows decode as empty). OCR crops are cut from the unrotated source image and brought into reading orientation before recognition (`MangaInferenceUtilities.readingOrientation` applies the page rotation only); the recognizer reads upright vertical text natively, so vertical columns must never be de-rotated — sideways glyphs are unreadable to it. Overlay text renders vertically when the region's resolved orientation says vertical — `unknown` from legacy caches falls back to a conservative normalized-box comparison — and the result's target language supports it (`MangaTranslationLanguage.supportsVerticalLayout`: Chinese/Japanese only, other targets stay horizontal); the `textDirection` preference (auto/horizontal/vertical) overrides the gate, with horizontal and vertical forcing one axis for every region. Vertical layout draws through CoreText vertical forms in `MangaVerticalTextCompositor`/`MangaVerticalTextLabel` with columns flowing right-to-left at a pinned 1.5em pitch, shrink-to-fit sizing, and a scale-to-fit fallback instead of clipping; the compositor also rewrites punctuation for vertical rendering (half-width to full-width, Unicode vertical presentation forms, one-cell compression of consecutive !?/?!/!!/?? with VS15 pinned on the emoji-capable marks), so the rendered text intentionally differs from the cached translation string. Loaded Core ML models are released when the reader closes and on memory warning without deleting their packages. Compute units are split per component: the detector runs on `.cpuAndGPU` (fixed 640×640 input, and `.all` distorts boxes on device) while the recognizer always runs on `.cpuOnly` — GPU/ANE specialize the decoder graph per sequence bucket and each specialization adds gigabytes of compile state, which memory-kills small devices when a decode crosses into long buckets; CPU speed is on par with flat memory. The mobile recognizer's encoder ships fp32 compute because fp16-compute convolutions diverge between ANE and CPU (CPU-run output flipped decoded glyphs), and the recognizer only ever runs on CPU. Live Text and local LLMs are not used. +- Manga translations are isolated per Komga instance: `MangaTranslationPageKey` includes the instance id, source/geometry/provider/model signatures, and rows use `(instance_id, cache_key)` as the primary key. Each save prunes same-page rows whose model signature retired (legacy empty signature, or the same archive on an older inference version); other archives and same-signature language/provider/geometry variants survive, so stale entries converge lazily as pages are revisited. Each attempt has an owner; cancelled or superseded attempts must not update reader state or overwrite newer cached results. The format-version-2 model catalog and matching package manifests declare capabilities and per-component engine/version/files/labels; detector and recognizer are separate adapters, unknown engines fail before download, and manga translation consumes only supported `manga_ocr` archives. Two detector engines exist: `rt_detr_v2` (text-tight boxes, the label table drives recognize/orientation) and `yolo11n_seg` (single `bubble` label, whole-bubble boxes; the CoreML export drops the mask-proto branch because regions carry boxes only, confidence arrives sigmoid-squashed in-graph, input is the same stretched 640×640). Two recognizer engines share one CoreML I/O contract and the `MangaGreedyDecoder` loop, differing only in preprocessing selected per engine via `MangaOCRPreprocessing`: `manga_ocr_transformer` (stretch to 224×224, [-1,1]) and `manga_ocr_mobile` (letterbox to 224×224 on white, [0,1]); the letterbox path resamples with a hand-rolled bilinear filter matching the training-time coordinate mapping — CoreGraphics scaling shifts pixel centers and flips glyphs. Detection and recognition models are selected independently per role (`MangaModelRole`, storage keys `manga_ocr.detector`/`.recognizer`, the legacy combined key serves as fallback until the next select, and is dropped once both roles have explicit picks — downgrading past the role keys then finds no selection, though downloads survive and re-selecting fixes it): an archive stays the distribution unit, but the two roles may come from different archives, the one-tap select writes only the roles the archive supports, the management sheet offers each role only role-capable archives (`MangaModelSet.supports(_:for:)`, which deliberately tolerates unknown extra components so future components do not break this build's downloads), and the translation service keeps one prepared `MangaModelSet` per selected archive (two at most, shared when both roles pick the same archive). The model signature is `::`, so cache pruning keys on the detector prefix and legacy single-archive signatures retire through the same prefix. The catalog ships only role-split archives (capabilities `detection` or `ocr` with a single component); the legacy combined archives were withdrawn from the catalog, and already-downloaded ones keep working because the downloaded-model registry is independent of the catalog. Model rows show the archive's capability as chips (`MangaModelCapabilityChips`, derived from its components) alongside the catalog comment. Bundles are downloaded from R2 with byte-level progress feeding the management row's progress bar, SHA-256 verified, and stored under `Application Support/Models` in per-purpose, per-archive namespaces excluded from device backups; hashing and TAR extraction stream off MainActor, the downloaded-model registry is independent of the current catalog, and models never ship in the app bundle. Translation uses a selected OpenAI-compatible provider when configured, otherwise the on-device system provider on iOS 26+/macOS 26+; older versions report missing configuration before OCR instead of doing wasted recognition. Provider configurations, including API keys, live in GRDB. OpenAI-compatible translation and model-list requests share a 30-second hard timeout in `MangaProviderRequest`. Source language prefers series metadata before the default, target language defaults to the system language, comic metadata is optional in the LLM prompt, and overlay geometry is clamped to the displayed page bounds; bubble backgrounds are feathered-edged rounded rectangles with an opaque center, tinted with the reader's sepia background, and the sizing follows the detector's box semantics — each page stores the detector's `MangaTextBoxKind` (`text` for text-tight rt_detr boxes, `bubble` for whole-bubble yolo boxes; legacy cached rows decode as `text`, and the rounded rectangle hugs the box for either kind) and `MangaBubbleBackground` derives the background and text frames from the box kind: the blob outgrows text-tight boxes by the feather so the glyphs stay covered, while whole-bubble boxes keep the blob and its feather inside the box so the bubble's own border line survives; whole-bubble boxes also shrink the text area to a centered fraction of the box so glyphs keep a manga-like white margin inside the bubble's own padding, while text-tight boxes use the small-inset text area. Detector labels without orientation metadata fall back to physical box geometry (taller than wide, compared in display pixels — normalized heights on portrait pages hide near-square vertical bubbles), and each `MangaPageTranslation` stores its `targetLanguage` (legacy rows decode as empty). OCR crops are cut from the unrotated source image and brought into reading orientation before recognition (`MangaInferenceUtilities.readingOrientation` applies the page rotation only); the recognizer reads upright vertical text natively, so vertical columns must never be de-rotated — sideways glyphs are unreadable to it. Overlay text renders vertically when the region's resolved orientation says vertical — `unknown` from legacy caches falls back to a conservative normalized-box comparison — and the result's target language supports it (`MangaTranslationLanguage.supportsVerticalLayout`: Chinese/Japanese only, other targets stay horizontal); the `textDirection` preference (auto/horizontal/vertical) overrides the gate, with horizontal and vertical forcing one axis for every region. Vertical layout draws through CoreText vertical forms in `MangaVerticalTextCompositor`/`MangaVerticalTextLabel` with columns flowing right-to-left at a pinned 1.5em pitch, shrink-to-fit sizing, and a scale-to-fit fallback instead of clipping; the compositor also rewrites punctuation for vertical rendering (half-width to full-width, Unicode vertical presentation forms, one-cell compression of consecutive !?/?!/!!/?? with VS15 pinned on the emoji-capable marks), so the rendered text intentionally differs from the cached translation string. Loaded Core ML models are released when the reader closes and on memory warning without deleting their packages. Compute units are split per component: the detector runs on `.cpuAndGPU` (fixed 640×640 input, and `.all` distorts boxes on device) while the recognizer always runs on `.cpuOnly` — GPU/ANE specialize the decoder graph per sequence bucket and each specialization adds gigabytes of compile state, which memory-kills small devices when a decode crosses into long buckets; CPU speed is on par with flat memory. The mobile recognizer's encoder ships fp32 compute because fp16-compute convolutions diverge between ANE and CPU (CPU-run output flipped decoded glyphs), and the recognizer only ever runs on CPU. Live Text and local LLMs are not used. ### Read List Reading State diff --git a/KMReader/Features/MangaTranslation/Models/MangaBubbleShape.swift b/KMReader/Features/MangaTranslation/Models/MangaBubbleShape.swift deleted file mode 100644 index d21256119..000000000 --- a/KMReader/Features/MangaTranslation/Models/MangaBubbleShape.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation - -/// User-selected bubble background shape; `MangaBubbleBackground` derives the -/// background and text geometry from it. -nonisolated enum MangaBubbleShape: String, Codable, Hashable, Sendable { - case ellipse - case roundedRect -} diff --git a/KMReader/Features/MangaTranslation/Models/MangaPageTranslation.swift b/KMReader/Features/MangaTranslation/Models/MangaPageTranslation.swift index 0462988b1..0d2999b49 100644 --- a/KMReader/Features/MangaTranslation/Models/MangaPageTranslation.swift +++ b/KMReader/Features/MangaTranslation/Models/MangaPageTranslation.swift @@ -41,7 +41,8 @@ nonisolated struct MangaPageTranslation: Codable, Hashable, Sendable { // language, which keeps their rendering horizontal. targetLanguage = try container.decodeIfPresent(String.self, forKey: .targetLanguage) ?? "" // Rows cached before the field existed fall back to text-tight geometry: - // its outgrown ellipse keeps corner text covered for either box kind. + // the rounded rectangle hugs the box for either kind, and the small-inset + // text area still lands inside the bubble. boxKind = try container.decodeIfPresent(MangaTextBoxKind.self, forKey: .boxKind) ?? .text createdAt = try container.decode(Date.self, forKey: .createdAt) } diff --git a/KMReader/Features/MangaTranslation/Models/MangaTranslationPreferences.swift b/KMReader/Features/MangaTranslation/Models/MangaTranslationPreferences.swift index 8b4040bac..0ed535db6 100644 --- a/KMReader/Features/MangaTranslation/Models/MangaTranslationPreferences.swift +++ b/KMReader/Features/MangaTranslation/Models/MangaTranslationPreferences.swift @@ -9,7 +9,6 @@ nonisolated struct MangaTranslationPreferences: Codable, Equatable, RawRepresent var includeMetadata: Bool var showTranslation: Bool var textDirection: MangaTranslationTextDirection - var bubbleShape: MangaBubbleShape init( selectedProviderID: String = "", @@ -17,8 +16,7 @@ nonisolated struct MangaTranslationPreferences: Codable, Equatable, RawRepresent targetLanguage: String = "", includeMetadata: Bool = false, showTranslation: Bool = true, - textDirection: MangaTranslationTextDirection = .auto, - bubbleShape: MangaBubbleShape = .ellipse + textDirection: MangaTranslationTextDirection = .auto ) { self.selectedProviderID = selectedProviderID self.sourceLanguage = sourceLanguage @@ -26,7 +24,6 @@ nonisolated struct MangaTranslationPreferences: Codable, Equatable, RawRepresent self.includeMetadata = includeMetadata self.showTranslation = showTranslation self.textDirection = textDirection - self.bubbleShape = bubbleShape } init?(rawValue: String) { @@ -48,8 +45,7 @@ nonisolated struct MangaTranslationPreferences: Codable, Equatable, RawRepresent includeMetadata: try container.decodeIfPresent(Bool.self, forKey: .includeMetadata) ?? false, showTranslation: try container.decodeIfPresent(Bool.self, forKey: .showTranslation) ?? true, textDirection: try container.decodeIfPresent( - MangaTranslationTextDirection.self, forKey: .textDirection) ?? .auto, - bubbleShape: try container.decodeIfPresent(MangaBubbleShape.self, forKey: .bubbleShape) ?? .ellipse + MangaTranslationTextDirection.self, forKey: .textDirection) ?? .auto ) } @@ -61,7 +57,6 @@ nonisolated struct MangaTranslationPreferences: Codable, Equatable, RawRepresent try container.encode(includeMetadata, forKey: .includeMetadata) try container.encode(showTranslation, forKey: .showTranslation) try container.encode(textDirection, forKey: .textDirection) - try container.encode(bubbleShape, forKey: .bubbleShape) } nonisolated var rawValue: String { @@ -78,6 +73,5 @@ nonisolated struct MangaTranslationPreferences: Codable, Equatable, RawRepresent case includeMetadata case showTranslation case textDirection - case bubbleShape } } diff --git a/KMReader/Features/MangaTranslation/Views/MangaBubbleBackground.swift b/KMReader/Features/MangaTranslation/Views/MangaBubbleBackground.swift index 0ba333668..9cfb2b1af 100644 --- a/KMReader/Features/MangaTranslation/Views/MangaBubbleBackground.swift +++ b/KMReader/Features/MangaTranslation/Views/MangaBubbleBackground.swift @@ -19,33 +19,30 @@ enum MangaBubbleBackground { min(32, max(12, min(size.width, size.height) * 0.4)) } - /// Text-tight boxes wrap the glyphs, so the ellipse must outgrow the box to - /// still cover its corners (√2 to touch them); whole-bubble boxes already - /// bound the bubble, and the rounded rectangle hugs the box for either kind. - static func backgroundFrame( - forBoxSize size: CGSize, boxKind: MangaTextBoxKind, shape: MangaBubbleShape - ) -> CGRect { - let coreScale: CGFloat - switch (shape, boxKind) { - case (.ellipse, .text): coreScale = 1.5 - case (.ellipse, .bubble), (.roundedRect, _): coreScale = 1 - } + /// Text-tight boxes wrap the glyphs, so the blob must grow past them by the + /// feather; whole-bubble boxes bound the bubble itself, so the blob and its + /// feather stay inside the box — the bubble's own border line then survives + /// instead of being painted over and blurred beyond it. + static func backgroundFrame(forBoxSize size: CGSize, boxKind: MangaTextBoxKind) -> CGRect { let feather = feather(forBoxSize: size) - let coreWidth = size.width * coreScale - let coreHeight = size.height * coreScale - return CGRect( - x: (size.width - coreWidth) / 2 - feather, - y: (size.height - coreHeight) / 2 - feather, - width: coreWidth + feather * 2, - height: coreHeight + feather * 2 - ) + switch boxKind { + case .text: + return CGRect( + x: -feather, + y: -feather, + width: size.width + feather * 2, + height: size.height + feather * 2 + ) + case .bubble: + return CGRect(x: 0, y: 0, width: size.width, height: size.height) + } } - static func textFrame(forBoxSize size: CGSize, boxKind: MangaTextBoxKind, shape: MangaBubbleShape) -> CGRect { - if shape == .ellipse && boxKind == .bubble { - // the inscribed rect (1/√2) touches the ellipse rim; whole-bubble boxes - // already include the bubble's padding, so the text area shrinks further - // to keep glyphs on the opaque core with a manga-like white margin + /// Whole-bubble boxes already include the bubble's own padding, so the text + /// area shrinks to a centered fraction of the box; text-tight boxes wrap the + /// glyphs and only need a small inset. + static func textFrame(forBoxSize size: CGSize, boxKind: MangaTextBoxKind) -> CGRect { + if boxKind == .bubble { let scale: CGFloat = 0.5 return CGRect( x: size.width * (1 - scale) / 2, @@ -60,10 +57,13 @@ enum MangaBubbleBackground { ) } - static func blobImage(size: CGSize, feather: CGFloat, shape: MangaBubbleShape, color: CGColor) -> CGImage? { + static func blobImage(size: CGSize, feather: CGFloat, color: CGColor) -> CGImage? { + // The opaque core must survive even on tiny boxes, so the feather can + // never eat half the box. + let feather = min(feather, (min(size.width, size.height) - 1) / 2) let width = Int(size.width.rounded()) let height = Int(size.height.rounded()) - guard width > 0, height > 0, feather * 2 < size.width, feather * 2 < size.height else { return nil } + guard width > 0, height > 0, feather > 0 else { return nil } guard let context = CGContext( data: nil, @@ -82,15 +82,10 @@ enum MangaBubbleBackground { height: size.height - feather * 2 ) context.setFillColor(color) - switch shape { - case .ellipse: - context.addPath(CGPath(ellipseIn: coreRect, transform: nil)) - case .roundedRect: - let radius = cornerRadius(forBoxSize: coreRect.size) - context.addPath( - CGPath(roundedRect: coreRect, cornerWidth: radius, cornerHeight: radius, transform: nil) - ) - } + let radius = cornerRadius(forBoxSize: coreRect.size) + context.addPath( + CGPath(roundedRect: coreRect, cornerWidth: radius, cornerHeight: radius, transform: nil) + ) context.fillPath() guard let solid = context.makeImage() else { return nil } // sigma ≈ feather/2.2 keeps the fade inside the feather band (α < 2% at the edge) diff --git a/KMReader/Features/MangaTranslation/Views/MangaBubbleBackgroundView.swift b/KMReader/Features/MangaTranslation/Views/MangaBubbleBackgroundView.swift index 4d9792568..2f0371ff0 100644 --- a/KMReader/Features/MangaTranslation/Views/MangaBubbleBackgroundView.swift +++ b/KMReader/Features/MangaTranslation/Views/MangaBubbleBackgroundView.swift @@ -10,14 +10,10 @@ import Foundation var feather: CGFloat = 10 { didSet { if feather != oldValue { setNeedsLayout() } } } - var shape: MangaBubbleShape = .ellipse { - didSet { if shape != oldValue { setNeedsLayout() } } - } private var renderedSize: CGSize? private var renderedColor: UIColor? private var renderedFeather: CGFloat = 0 - private var renderedShape: MangaBubbleShape? override init(frame: CGRect) { super.init(frame: frame) @@ -41,16 +37,13 @@ import Foundation renderedSize = nil return } - guard - size != renderedSize || fillColor != renderedColor || feather != renderedFeather - || shape != renderedShape + guard size != renderedSize || fillColor != renderedColor || feather != renderedFeather else { return } renderedSize = size renderedColor = fillColor renderedFeather = feather - renderedShape = shape layer.contents = MangaBubbleBackground.blobImage( - size: size, feather: feather, shape: shape, color: fillColor.cgColor) + size: size, feather: feather, color: fillColor.cgColor) } } #elseif os(macOS) @@ -63,14 +56,10 @@ import Foundation var feather: CGFloat = 10 { didSet { if feather != oldValue { needsLayout = true } } } - var shape: MangaBubbleShape = .ellipse { - didSet { if shape != oldValue { needsLayout = true } } - } private var renderedSize: CGSize? private var renderedColor: NSColor? private var renderedFeather: CGFloat = 0 - private var renderedShape: MangaBubbleShape? override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -92,16 +81,13 @@ import Foundation renderedSize = nil return } - guard - size != renderedSize || fillColor != renderedColor || feather != renderedFeather - || shape != renderedShape + guard size != renderedSize || fillColor != renderedColor || feather != renderedFeather else { return } renderedSize = size renderedColor = fillColor renderedFeather = feather - renderedShape = shape layer?.contents = MangaBubbleBackground.blobImage( - size: size, feather: feather, shape: shape, color: fillColor.cgColor) + size: size, feather: feather, color: fillColor.cgColor) } } #endif diff --git a/KMReader/Features/MangaTranslation/Views/MangaOverlayView.swift b/KMReader/Features/MangaTranslation/Views/MangaOverlayView.swift index b7135baad..c679c3ef1 100644 --- a/KMReader/Features/MangaTranslation/Views/MangaOverlayView.swift +++ b/KMReader/Features/MangaTranslation/Views/MangaOverlayView.swift @@ -8,7 +8,6 @@ import SwiftUI private var visible = true private var background: ReaderBackground = .system private var direction: MangaTranslationTextDirection = .auto - private var shape: MangaBubbleShape = .ellipse private var sourceImageID: ObjectIdentifier? private var regionColors: [CGColor] = [] @@ -28,14 +27,13 @@ import SwiftUI func update( result: MangaPageTranslation?, visible: Bool, background: ReaderBackground, - direction: MangaTranslationTextDirection, shape: MangaBubbleShape, image: PlatformImage? + direction: MangaTranslationTextDirection, image: PlatformImage? ) { self.visible = visible let backgroundChanged = self.background != background self.background = background let directionChanged = self.direction != direction self.direction = direction - self.shape = shape let imageID = image.map { ObjectIdentifier($0) } let imageChanged = imageID != sourceImageID sourceImageID = imageID @@ -122,13 +120,12 @@ import SwiftUI $0 is MangaBubbleBackgroundView }) as? MangaBubbleBackgroundView { bubbleBackground.frame = MangaBubbleBackground.backgroundFrame( - forBoxSize: container.bounds.size, boxKind: result.boxKind, shape: shape) + forBoxSize: container.bounds.size, boxKind: result.boxKind) bubbleBackground.feather = feather - bubbleBackground.shape = shape } let textFrame = MangaBubbleBackground.textFrame( - forBoxSize: container.bounds.size, boxKind: result.boxKind, shape: shape) + forBoxSize: container.bounds.size, boxKind: result.boxKind) guard let child = container.subviews.first(where: { $0 is UILabel || $0 is MangaVerticalTextLabel @@ -151,14 +148,13 @@ import SwiftUI private func fitFont(for bubble: UILabel) { guard bubble.bounds.width > 0, bubble.bounds.height > 0 else { return } var fontSize = min(17, max(12, bubble.bounds.height * 0.35)) - for _ in 0..<4 { + for _ in 0..<5 { bubble.font = .systemFont(ofSize: fontSize, weight: .medium) let neededSize = bubble.sizeThatFits( CGSize(width: bubble.bounds.width, height: .greatestFiniteMagnitude) ) if neededSize.height <= bubble.bounds.height + 0.5 { break } - let scale = max(0.7, bubble.bounds.height / max(neededSize.height, 1)) - let nextSize = max(8, fontSize * scale) + let nextSize = max(8, fontSize * 0.85) if nextSize == fontSize { break } fontSize = nextSize } @@ -175,7 +171,6 @@ import SwiftUI private var visible = true private var background: ReaderBackground = .system private var direction: MangaTranslationTextDirection = .auto - private var shape: MangaBubbleShape = .ellipse private var sourceImageID: ObjectIdentifier? private var regionColors: [CGColor] = [] @@ -195,14 +190,13 @@ import SwiftUI func update( result: MangaPageTranslation?, visible: Bool, background: ReaderBackground, - direction: MangaTranslationTextDirection, shape: MangaBubbleShape, image: PlatformImage? + direction: MangaTranslationTextDirection, image: PlatformImage? ) { self.visible = visible let backgroundChanged = self.background != background self.background = background let directionChanged = self.direction != direction self.direction = direction - self.shape = shape let imageID = image.map { ObjectIdentifier($0) } let imageChanged = imageID != sourceImageID sourceImageID = imageID @@ -292,13 +286,12 @@ import SwiftUI $0 is MangaBubbleBackgroundView }) as? MangaBubbleBackgroundView { bubbleBackground.frame = MangaBubbleBackground.backgroundFrame( - forBoxSize: container.bounds.size, boxKind: result.boxKind, shape: shape) + forBoxSize: container.bounds.size, boxKind: result.boxKind) bubbleBackground.feather = feather - bubbleBackground.shape = shape } let textFrame = MangaBubbleBackground.textFrame( - forBoxSize: container.bounds.size, boxKind: result.boxKind, shape: shape) + forBoxSize: container.bounds.size, boxKind: result.boxKind) guard let child = container.subviews.first(where: { $0 is NSTextField || $0 is MangaVerticalTextLabel @@ -321,14 +314,13 @@ import SwiftUI private func fitFont(for bubble: NSTextField) { guard bubble.bounds.width > 0, bubble.bounds.height > 0 else { return } var fontSize = min(17, max(12, bubble.bounds.height * 0.35)) - for _ in 0..<4 { + for _ in 0..<5 { bubble.font = .systemFont(ofSize: fontSize, weight: .medium) let neededSize = bubble.sizeThatFits( CGSize(width: bubble.bounds.width, height: .greatestFiniteMagnitude) ) if neededSize.height <= bubble.bounds.height + 0.5 { break } - let scale = max(0.7, bubble.bounds.height / max(neededSize.height, 1)) - let nextSize = max(8, fontSize * scale) + let nextSize = max(8, fontSize * 0.85) if nextSize == fontSize { break } fontSize = nextSize } diff --git a/KMReader/Features/MangaTranslation/Views/MangaTranslationSettingsView.swift b/KMReader/Features/MangaTranslation/Views/MangaTranslationSettingsView.swift index dd9d40424..283edcd9d 100644 --- a/KMReader/Features/MangaTranslation/Views/MangaTranslationSettingsView.swift +++ b/KMReader/Features/MangaTranslation/Views/MangaTranslationSettingsView.swift @@ -49,10 +49,6 @@ struct MangaTranslationSettingsView: View { Text(String(localized: "Horizontal")).tag(MangaTranslationTextDirection.horizontal) Text(String(localized: "Vertical")).tag(MangaTranslationTextDirection.vertical) } - Picker(String(localized: "Bubble Shape"), selection: $preferences.bubbleShape) { - Text(String(localized: "Ellipse")).tag(MangaBubbleShape.ellipse) - Text(String(localized: "Rounded Rectangle")).tag(MangaBubbleShape.roundedRect) - } } Section( diff --git a/KMReader/Features/Reader/Views/Divina/PageImage/NativePageItem_iOS.swift b/KMReader/Features/Reader/Views/Divina/PageImage/NativePageItem_iOS.swift index d5a74dfc5..00702e808 100644 --- a/KMReader/Features/Reader/Views/Divina/PageImage/NativePageItem_iOS.swift +++ b/KMReader/Features/Reader/Views/Divina/PageImage/NativePageItem_iOS.swift @@ -313,7 +313,6 @@ visible: viewModel.mangaTranslationOverlayVisible(for: data.pageID, splitMode: data.splitMode), background: background, direction: AppConfig.mangaTranslation.textDirection, - shape: AppConfig.mangaTranslation.bubbleShape, image: pageSourceImage ) #if os(iOS) diff --git a/KMReader/Features/Reader/Views/Divina/PageImage/NativePageItem_macOS.swift b/KMReader/Features/Reader/Views/Divina/PageImage/NativePageItem_macOS.swift index 8d7a1cb6c..2f183b7b2 100644 --- a/KMReader/Features/Reader/Views/Divina/PageImage/NativePageItem_macOS.swift +++ b/KMReader/Features/Reader/Views/Divina/PageImage/NativePageItem_macOS.swift @@ -335,7 +335,6 @@ for: data.pageID, splitMode: data.splitMode) ?? false, background: background, direction: AppConfig.mangaTranslation.textDirection, - shape: AppConfig.mangaTranslation.bubbleShape, image: pageSourceImage ) updateTranslationButton() diff --git a/KMReader/Localizable.xcstrings b/KMReader/Localizable.xcstrings index 9883a56d4..192b614ce 100644 --- a/KMReader/Localizable.xcstrings +++ b/KMReader/Localizable.xcstrings @@ -10829,70 +10829,6 @@ } } }, - "Bubble Shape" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sprechblasenform" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bubble Shape" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Forma de la burbuja" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Forme de bulle" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Forma della nuvoletta" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "吹き出しの形" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "말풍선 모양" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Форма облачка" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "气泡形状" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "氣泡形狀" - } - } - } - }, "Bulk Actions" : { "localizations" : { "de" : { @@ -23758,70 +23694,6 @@ } } }, - "Ellipse" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ellipse" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ellipse" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Elipse" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ellipse" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ellisse" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "楕円" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "타원" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Эллипс" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "椭圆" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "橢圓" - } - } - } - }, "Email" : { "localizations" : { "de" : { @@ -72287,70 +72159,6 @@ } } }, - "Rounded Rectangle" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Abgerundetes Rechteck" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rounded Rectangle" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rectángulo redondeado" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rectangle arrondi" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rettangolo arrotondato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "角丸四角" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "둥근 사각형" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Скруглённый прямоугольник" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "圆角矩形" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "圓角矩形" - } - } - } - }, "s" : { "localizations" : { "de" : {