Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/components/ConverterPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export function ConverterPage({ fileType, notice }: ConverterPageProps) {
device: 'X4',
splitMode: (fileType === 'image' || fileType === 'video') ? 'nosplit' : 'overlap',
dithering: fileType === 'pdf' ? 'atkinson' : 'floyd',
is2bit: false,
contrast: fileType === 'pdf' ? 8 : 4,
horizontalMargin: 0,
verticalMargin: 0,
Expand Down Expand Up @@ -241,7 +242,7 @@ export function ConverterPage({ fileType, notice }: ConverterPageProps) {
console.error(`Error converting ${file.name}:`, err)
// Store error result
await addResult({
name: file.name.replace(/\.[^/.]+$/i, '.xtc'),
name: file.name.replace(/\.[^/.]+$/i, options.is2bit ? '.xtch' : '.xtc'),
error: normalizeUserErrorMessage(err instanceof Error ? err.message : 'Unknown error'),
})
}
Expand Down
14 changes: 7 additions & 7 deletions src/components/MergePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export function MergePage() {

const type = detectFileType(file)
if (type === 'unknown') {
setTypeError('Unsupported file type. Use CBZ, PDF, or XTC files.')
setTypeError('Unsupported file type. Use CBZ, PDF, XTC, or XTCH files.')
return
}

Expand Down Expand Up @@ -318,14 +318,14 @@ export function MergePage() {
{mode === 'merge' ? 'Drop files to merge' : 'Drop a file to split'}
</span>
<span className="dropzone-secondary">
CBZ, PDF, or XTC {mode === 'merge' ? '(same type only)' : ''}
CBZ, PDF, or XTC/XTCH {mode === 'merge' ? '(same type only)' : ''}
</span>
</div>
</div>
<input
id="merge-file-input"
type="file"
accept=".cbz,.CBZ,.pdf,.PDF,.xtc,.XTC"
accept=".cbz,.CBZ,.pdf,.PDF,.xtc,.XTC,.xtch,.XTCH"
multiple={mode === 'merge'}
hidden
onChange={handleFileInput}
Expand Down Expand Up @@ -391,7 +391,7 @@ export function MergePage() {
value={xtcOutputFormat}
onChange={(e) => setXtcOutputFormat(e.target.value as OutputFormat)}
>
<option value="xtc">XTC (E-Reader)</option>
<option value="xtc">XTC/XTCH (E-Reader)</option>
<option value="cbz">CBZ (Archive)</option>
</select>
</div>
Expand All @@ -404,7 +404,7 @@ export function MergePage() {
<div className="output-info">
<span className="output-format-badge">CBZ</span>
<span className="output-hint">
Move to converter to create XTC
Move to converter to create XTC/XTCH
</span>
</div>
</div>
Expand All @@ -417,7 +417,7 @@ export function MergePage() {
<div className="output-info">
<span className="output-format-badge">PDF</span>
<span className="output-hint">
Move to converter to create XTC
Move to converter to create XTC/XTCH
</span>
</div>
</div>
Expand Down Expand Up @@ -599,7 +599,7 @@ export function MergePage() {
className="btn-move-converter"
onClick={handleMoveToConverter}
>
Convert {selectedResults.length > 1 ? `${selectedResults.length} files` : ''} to XTC
Convert {selectedResults.length > 1 ? `${selectedResults.length} files` : ''} to XTC/XTCH
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
Expand Down
12 changes: 12 additions & 0 deletions src/components/Options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,18 @@ export function Options({ options, onChange, fileType = 'cbz' }: OptionsProps) {
</select>
</div>

<div className="option option-checkbox">
<label htmlFor="is2bit" className="checkbox-label">
<input
type="checkbox"
id="is2bit"
checked={options.is2bit}
onChange={(e) => onChange({ ...options, is2bit: e.target.checked })}
/>
<span>Use XTCH 2-bit grayscale output</span>
</label>
</div>

<div className="options-actions">
<button
type="button"
Expand Down
1 change: 1 addition & 0 deletions src/lib/conversion/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export interface ConversionOptions {
device: 'X4' | 'X3'
splitMode: string
dithering: string
is2bit: boolean
contrast: number
horizontalMargin: number
verticalMargin: number
Expand Down
84 changes: 49 additions & 35 deletions src/lib/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import unrarWasm from 'node-unrar-js/esm/js/unrar.wasm?url'
import { applyDithering } from './processing/dithering'
import { toGrayscale, applyContrast, calculateOverlapSegments } from './processing/image'
import { rotateCanvas, extractAndRotate, resizeWithPadding, getTargetDimensions } from './processing/canvas'
import { imageDataToXtg } from './processing/xtg'
import { imageDataToXtg, imageDataToXth } from './processing/xtg'
import { buildXtcFromXtgPages } from './xtc-format'
import { extractPdfMetadata } from './metadata/pdf-outline'
import { parseComicInfo } from './metadata/comicinfo'
Expand Down Expand Up @@ -222,12 +222,12 @@ async function blobToDataUrl(blob: Blob): Promise<string> {
})
}

function encodeCanvasPage(page: ProcessedPage): EncodedPage {
function encodeCanvasPage(page: ProcessedPage, options: ConversionOptions): EncodedPage {
const ctx = page.canvas.getContext('2d')!
const imageData = ctx.getImageData(0, 0, page.canvas.width, page.canvas.height)
return {
name: page.name,
xtg: imageDataToXtg(imageData)
xtg: options.is2bit ? imageDataToXth(imageData) : imageDataToXtg(imageData)
}
}

Expand All @@ -236,15 +236,19 @@ async function finalizeConversionResult(
encodedPages: EncodedPage[],
mappingCtx: PageMappingContext,
metadata: BookMetadata,
sampledPreviews: string[]
sampledPreviews: string[],
options: ConversionOptions
): Promise<ConversionResult> {
encodedPages.sort((a, b) => a.name.localeCompare(b.name))

if (metadata.toc.length > 0) {
metadata.toc = adjustTocForMapping(metadata.toc, mappingCtx)
}

const xtcData = await buildXtcFromXtgPages(encodedPages.map((page) => page.xtg), { metadata })
const xtcData = await buildXtcFromXtgPages(encodedPages.map((page) => page.xtg), {
metadata,
is2bit: options.is2bit
})

return {
name: outputName,
Expand All @@ -256,6 +260,17 @@ async function finalizeConversionResult(
}
}

function getOutputExtension(options: ConversionOptions): '.xtc' | '.xtch' {
return options.is2bit ? '.xtch' : '.xtc'
}

function replaceOutputExtension(fileName: string, options: ConversionOptions): string {
const extension = getOutputExtension(options)
const dot = fileName.lastIndexOf('.')
if (dot <= 0) return `${fileName}${extension}`
return `${fileName.slice(0, dot)}${extension}`
}

async function processArchiveSourcePages(
totalPages: number,
getBlob: (index: number) => Promise<Blob>,
Expand Down Expand Up @@ -327,7 +342,7 @@ async function processArchiveSourcePages(

if (pageResults.length === 0) {
const pages = await processImage(imgBlob, pageNum, pageOptions)
pageResults = pages.map(encodeCanvasPage)
pageResults = pages.map((page) => encodeCanvasPage(page, pageOptions))

if (includePreview && pages.length > 0 && pages[0].canvas) {
const previewDataUrl = pages[0].canvas.toDataURL('image/jpeg', PREVIEW_JPEG_QUALITY)
Expand Down Expand Up @@ -447,11 +462,12 @@ export async function convertCbzToXtc(
)

return finalizeConversionResult(
file.name.replace(/\.cbz$/i, '.xtc'),
replaceOutputExtension(file.name, options),
encodedPages,
mappingCtx,
metadata,
sampledPreviews
sampledPreviews,
options
)
}

Expand Down Expand Up @@ -531,20 +547,15 @@ export async function convertCbrToXtc(
)

return finalizeConversionResult(
file.name.replace(/\.cbr$/i, '.xtc'),
replaceOutputExtension(file.name, options),
encodedPages,
mappingCtx,
metadata,
sampledPreviews
sampledPreviews,
options
)
}

function getOutputName(fileName: string): string {
const dot = fileName.lastIndexOf('.')
if (dot <= 0) return `${fileName}.xtc`
return `${fileName.slice(0, dot)}.xtc`
}

/**
* Convert a single image file to XTC.
*/
Expand All @@ -562,7 +573,7 @@ export async function convertImageToXtc(
throw new Error('Failed to decode image')
}

const encodedPages = imagePages.map(encodeCanvasPage)
const encodedPages = imagePages.map((page) => encodeCanvasPage(page, options))
const mappingCtx = new PageMappingContext()
mappingCtx.addOriginalPage(1, imagePages.length)

Expand All @@ -575,11 +586,12 @@ export async function convertImageToXtc(
onProgress(1, previewUrl)

return finalizeConversionResult(
getOutputName(file.name),
replaceOutputExtension(file.name, options),
encodedPages,
mappingCtx,
{ toc: [] },
sampledPreviews
sampledPreviews,
options
)
}

Expand Down Expand Up @@ -673,7 +685,7 @@ export async function convertVideoToXtc(
captureCtx.drawImage(video, 0, 0, captureCanvas.width, captureCanvas.height)

const pages = processCanvasAsImage(captureCanvas, i + 1, frameOptions)
encodedPages.push(...pages.map(encodeCanvasPage))
encodedPages.push(...pages.map((page) => encodeCanvasPage(page, options)))
mappingCtx.addOriginalPage(i + 1, pages.length)

const includePreview = options.showProgressPreview &&
Expand All @@ -689,11 +701,12 @@ export async function convertVideoToXtc(
}

return finalizeConversionResult(
getOutputName(file.name),
replaceOutputExtension(file.name, options),
encodedPages,
mappingCtx,
{ toc: [] },
sampledPreviews
sampledPreviews,
options
)
} finally {
URL.revokeObjectURL(url)
Expand Down Expand Up @@ -741,7 +754,7 @@ async function convertPdfToXtc(
}).promise

const pages = processCanvasAsImage(canvas, i, options)
encodedPages.push(...pages.map(encodeCanvasPage))
encodedPages.push(...pages.map((page) => encodeCanvasPage(page, options)))
mappingCtx.addOriginalPage(i, pages.length)

const includePreview = options.showProgressPreview &&
Expand All @@ -761,11 +774,12 @@ async function convertPdfToXtc(
}

return finalizeConversionResult(
file.name.replace(/\.pdf$/i, '.xtc'),
replaceOutputExtension(file.name, options),
encodedPages,
mappingCtx,
metadata,
sampledPreviews
sampledPreviews,
options
)
}

Expand Down Expand Up @@ -810,7 +824,7 @@ function processCanvasAsImage(
options.imageMode,
255
)
applyDithering(finalCanvas.getContext('2d')!, targetWidth, targetHeight, options.dithering)
applyDithering(finalCanvas.getContext('2d')!, targetWidth, targetHeight, options.dithering, options.is2bit)

results.push({
name: `${String(pageNum).padStart(4, '0')}_0_page.png`,
Expand All @@ -829,7 +843,7 @@ function processCanvasAsImage(
const letter = String.fromCharCode(97 + idx)
const pageCanvas = extractAndRotate(canvas, seg.x, seg.y, seg.w, seg.h, landscapeRotation)
const finalCanvas = resizeWithPadding(pageCanvas, 255, targetWidth, targetHeight)
applyDithering(finalCanvas.getContext('2d')!, targetWidth, targetHeight, options.dithering)
applyDithering(finalCanvas.getContext('2d')!, targetWidth, targetHeight, options.dithering, options.is2bit)

results.push({
name: `${String(pageNum).padStart(4, '0')}_3_${letter}.png`,
Expand All @@ -841,15 +855,15 @@ function processCanvasAsImage(

const topCanvas = extractAndRotate(canvas, 0, 0, width, halfHeight, landscapeRotation)
const topFinal = resizeWithPadding(topCanvas, 255, targetWidth, targetHeight)
applyDithering(topFinal.getContext('2d')!, targetWidth, targetHeight, options.dithering)
applyDithering(topFinal.getContext('2d')!, targetWidth, targetHeight, options.dithering, options.is2bit)
results.push({
name: `${String(pageNum).padStart(4, '0')}_2_a.png`,
canvas: topFinal
})

const bottomCanvas = extractAndRotate(canvas, 0, halfHeight, width, halfHeight, landscapeRotation)
const bottomFinal = resizeWithPadding(bottomCanvas, 255, targetWidth, targetHeight)
applyDithering(bottomFinal.getContext('2d')!, targetWidth, targetHeight, options.dithering)
applyDithering(bottomFinal.getContext('2d')!, targetWidth, targetHeight, options.dithering, options.is2bit)
results.push({
name: `${String(pageNum).padStart(4, '0')}_2_b.png`,
canvas: bottomFinal
Expand All @@ -858,7 +872,7 @@ function processCanvasAsImage(
} else {
const rotatedCanvas = rotateCanvas(canvas, landscapeRotation)
const finalCanvas = resizeWithPadding(rotatedCanvas, 255, targetWidth, targetHeight)
applyDithering(finalCanvas.getContext('2d')!, targetWidth, targetHeight, options.dithering)
applyDithering(finalCanvas.getContext('2d')!, targetWidth, targetHeight, options.dithering, options.is2bit)

results.push({
name: `${String(pageNum).padStart(4, '0')}_0_spread.png`,
Expand Down Expand Up @@ -936,7 +950,7 @@ function processLoadedImage(
options.imageMode,
255
)
applyDithering(finalCanvas.getContext('2d')!, targetWidth, targetHeight, options.dithering)
applyDithering(finalCanvas.getContext('2d')!, targetWidth, targetHeight, options.dithering, options.is2bit)

results.push({
name: `${String(pageNum).padStart(4, '0')}_0_page.png`,
Expand All @@ -955,7 +969,7 @@ function processLoadedImage(
const letter = String.fromCharCode(97 + idx)
const pageCanvas = extractAndRotate(canvas, seg.x, seg.y, seg.w, seg.h, landscapeRotation)
const finalCanvas = resizeWithPadding(pageCanvas, 255, targetWidth, targetHeight)
applyDithering(finalCanvas.getContext('2d')!, targetWidth, targetHeight, options.dithering)
applyDithering(finalCanvas.getContext('2d')!, targetWidth, targetHeight, options.dithering, options.is2bit)

results.push({
name: `${String(pageNum).padStart(4, '0')}_3_${letter}.png`,
Expand All @@ -967,15 +981,15 @@ function processLoadedImage(

const topCanvas = extractAndRotate(canvas, 0, 0, width, halfHeight, landscapeRotation)
const topFinal = resizeWithPadding(topCanvas, 255, targetWidth, targetHeight)
applyDithering(topFinal.getContext('2d')!, targetWidth, targetHeight, options.dithering)
applyDithering(topFinal.getContext('2d')!, targetWidth, targetHeight, options.dithering, options.is2bit)
results.push({
name: `${String(pageNum).padStart(4, '0')}_2_a.png`,
canvas: topFinal
})

const bottomCanvas = extractAndRotate(canvas, 0, halfHeight, width, halfHeight, landscapeRotation)
const bottomFinal = resizeWithPadding(bottomCanvas, 255, targetWidth, targetHeight)
applyDithering(bottomFinal.getContext('2d')!, targetWidth, targetHeight, options.dithering)
applyDithering(bottomFinal.getContext('2d')!, targetWidth, targetHeight, options.dithering, options.is2bit)
results.push({
name: `${String(pageNum).padStart(4, '0')}_2_b.png`,
canvas: bottomFinal
Expand All @@ -984,7 +998,7 @@ function processLoadedImage(
} else {
const rotatedCanvas = rotateCanvas(canvas, landscapeRotation)
const finalCanvas = resizeWithPadding(rotatedCanvas, 255, targetWidth, targetHeight)
applyDithering(finalCanvas.getContext('2d')!, targetWidth, targetHeight, options.dithering)
applyDithering(finalCanvas.getContext('2d')!, targetWidth, targetHeight, options.dithering, options.is2bit)

results.push({
name: `${String(pageNum).padStart(4, '0')}_0_spread.png`,
Expand Down
Loading