From df2d5d70cc622d1a27104ffee7c3f0e960c9a5c1 Mon Sep 17 00:00:00 2001 From: Cameron Otsuka Date: Tue, 11 Aug 2026 12:47:18 -0700 Subject: [PATCH] streamline featureset --- README.md | 11 +- .../java/com/alexandria/reader/EInkPalette.kt | 1 - .../java/com/alexandria/reader/EpubParser.kt | 370 +++--------------- .../main/java/com/alexandria/reader/Models.kt | 57 --- .../com/alexandria/reader/ReaderScreen.kt | 208 +--------- .../com/alexandria/reader/ReaderWebView.kt | 61 --- scripts/verify-fixed-layout.ps1 | 97 +++++ scripts/verify-fixed-page-tools.ps1 | 151 ------- .../fixtures/fixed-layout-src/OEBPS/fixed.css | 6 +- .../fixed-layout-src/OEBPS/gradient.png | Bin 0 -> 2060 bytes .../fixtures/fixed-layout-src/OEBPS/nav.xhtml | 2 +- .../fixed-layout-src/OEBPS/package.opf | 5 +- .../fixed-layout-src/OEBPS/page1.xhtml | 2 +- .../fixed-layout-src/OEBPS/page2.xhtml | 2 +- .../fixed-layout-src/OEBPS/page3.xhtml | 2 +- .../fixed-layout-src/OEBPS/page4.xhtml | 2 +- tests/fixtures/fixed-layout.epub | Bin 4489 -> 5383 bytes 17 files changed, 160 insertions(+), 817 deletions(-) create mode 100644 scripts/verify-fixed-layout.ps1 delete mode 100644 scripts/verify-fixed-page-tools.ps1 create mode 100644 tests/fixtures/fixed-layout-src/OEBPS/gradient.png diff --git a/README.md b/README.md index f0115ed..a71851c 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,9 @@ The app requests no network or shared-storage permission. It imports each select - Text selection, highlights, notes, annotation editing, and annotation export - System dictionary and text-processing integration - Publisher or Atkinson Hyperlegible Next typeface, plus text size, line height, margin, alignment, publisher-style, and hyphenation controls -- One high-contrast e-ink appearance with colors aligned to the display's 16 grayscale levels, plus contrast and screen-brightness controls -- Fixed-page fit-to-page, fit-to-width page streams with line-preserving cuts, and 50–400% custom zoom and bounded pan -- Shared or separate even/odd crop margins with a visual crop editor -- Plato-compatible contrast exponent and gray-point controls, plus 16-level and black-and-white spatial dithering +- One high-contrast e-ink appearance with colors aligned to the display's 16 grayscale levels, plus screen-brightness control +- Automatic fit-to-page rendering for pre-paginated EPUBs +- Automatic 16-level grayscale spatial dithering for images - Portrait and landscape layouts - Searchable and sortable library with covers and reading states @@ -57,11 +56,11 @@ Use the EPUB 2 fixture to verify OPF 2 metadata, cover extraction, NCX navigatio Run the focused reader tests separately: ```powershell -.\scripts\verify-fixed-page-tools.ps1 +.\scripts\verify-fixed-layout.ps1 .\scripts\verify-search-history.ps1 ``` -The fixed-page test verifies fit modes, continuous line cuts, even/odd crops, custom pan persistence, gray-point adjustment, and dithering. The search/history test verifies forward and backward searches from page two, result-jump history, an internal-link jump, and previous-location returns. Both write screenshots under `verification/`. +The fixed-layout test verifies automatic page fitting, image dithering, navigation, and position restoration. The search/history test verifies forward and backward searches from page two, result-jump history, an internal-link jump, and previous-location returns. Both write screenshots under `verification/`. To start a clean AVD before manual testing, use: diff --git a/app/src/main/java/com/alexandria/reader/EInkPalette.kt b/app/src/main/java/com/alexandria/reader/EInkPalette.kt index 791a168..fe7bef5 100644 --- a/app/src/main/java/com/alexandria/reader/EInkPalette.kt +++ b/app/src/main/java/com/alexandria/reader/EInkPalette.kt @@ -14,7 +14,6 @@ internal object EInkPalette { val SECONDARY_INK = gray(4) val MUTED_INK = gray(6) val DIVIDER = gray(11) - val PAGE_PREVIEW = gray(13) val SURFACE = gray(14) val PAPER = gray(15) diff --git a/app/src/main/java/com/alexandria/reader/EpubParser.kt b/app/src/main/java/com/alexandria/reader/EpubParser.kt index f1c7601..1208eac 100644 --- a/app/src/main/java/com/alexandria/reader/EpubParser.kt +++ b/app/src/main/java/com/alexandria/reader/EpubParser.kt @@ -348,7 +348,6 @@ object EpubHtmlBuilder { val directory: String, val viewportWidth: Int, val viewportHeight: Int, - val lineBoxes: String, ) fun write(publication: EpubPublication, output: File): File { @@ -369,11 +368,6 @@ object EpubHtmlBuilder { } ChapterDocument( index, item, document, body, item.href.substringBeforeLast('/', ""), viewport.first, viewport.second, - if (publication.fixedLayout) { - runCatching { fixedLineBoxes(document, item.href.substringBeforeLast('/', ""), publication.rootDirectory) } - .onFailure { android.util.Log.w("AlexandriaRenderer", "Could not derive fixed-page line boxes for ${item.href}", it) } - .getOrDefault("") - } else "", ) } val targetIds = mutableMapOf() @@ -477,8 +471,7 @@ object EpubHtmlBuilder { .append(chapter.index).append("\" data-spine=\"").append(chapter.index).append('"') if (publication.fixedLayout) { append(" data-page-width=\"").append(chapter.viewportWidth) - .append("\" data-page-height=\"").append(chapter.viewportHeight) - .append("\" data-line-boxes=\"").append(chapter.lineBoxes).append('"') + .append("\" data-page-height=\"").append(chapter.viewportHeight).append('"') } if (bodyStyle.isNotBlank()) append(" style=\"").append(escapeAttribute(bodyStyle)).append('"') if (language.isNotBlank()) append(" lang=\"").append(escapeAttribute(language)).append('"') @@ -496,80 +489,6 @@ object EpubHtmlBuilder { return output } - private fun fixedLineBoxes(document: Document, directory: String, root: File): String { - data class Metrics(var top: Float? = null, var height: Float? = null, var fontSize: Float? = null, var lineHeight: Float? = null) - fun number(value: String): Float? = Regex("-?[0-9]+(?:\\.[0-9]+)?").find(value) - ?.value?.toFloatOrNull() - fun property(css: String, name: String): String? = Regex("(?:^|;)\\s*${Regex.escape(name)}\\s*:\\s*([^;]+)", RegexOption.IGNORE_CASE) - .find(css)?.groupValues?.getOrNull(1)?.trim() - fun apply(css: String, value: Metrics) { - property(css, "top")?.let(::number)?.let { value.top = it } - property(css, "height")?.let(::number)?.let { value.height = it } - val shorthand = property(css, "font") - val shorthandSize = shorthand?.let { Regex("([0-9]+(?:\\.[0-9]+)?)(?:px|pt)(?:\\s*/\\s*([0-9]+(?:\\.[0-9]+)?)(?:px|pt)?)?", RegexOption.IGNORE_CASE).find(it) } - val fontSize = property(css, "font-size")?.let(::number) - ?: shorthandSize?.groupValues?.getOrNull(1)?.toFloatOrNull() - if (fontSize != null) value.fontSize = fontSize - val rawLineHeight = property(css, "line-height") - val lineNumber = rawLineHeight?.let(::number) - ?: shorthandSize?.groupValues?.getOrNull(2)?.toFloatOrNull() - if (lineNumber != null) { - val absolute = rawLineHeight?.contains(Regex("px|pt", RegexOption.IGNORE_CASE)) == true - value.lineHeight = if (absolute || lineNumber > 4f) lineNumber else lineNumber * (value.fontSize ?: 16f) - } - } - - val metrics = mutableMapOf() - val css = buildString { - val loaded = mutableSetOf() - document.getAllElements().filter { it.tagName().substringAfter(':').equals("link", true) }.forEach { link -> - if ("stylesheet" in link.attr("rel").lowercase(Locale.ROOT)) { - val href = link.attr("href") - if (href.isNotBlank() && !isExternal(href)) { - append(loadStylesheet(EpubParser.normalizePath(directory, href.substringBefore('#')), root, loaded)).append('\n') - } - } - } - document.getAllElements().filter { it.tagName().substringAfter(':').equals("style", true) } - .forEach { append(it.data().ifBlank { it.html() }).append('\n') } - } - Regex("([^{}]+)\\{([^{}]*)\\}", RegexOption.DOT_MATCHES_ALL).findAll(css).forEach { rule -> - if (rule.groupValues[1].trimStart().startsWith('@')) return@forEach - rule.groupValues[1].split(',').forEach { rawSelector -> - val selector = rawSelector.trim().replace(Regex("::?[A-Za-z-]+(?:\\([^)]*\\))?"), "") - if (selector.isBlank()) return@forEach - runCatching { document.select(selector) }.getOrDefault(emptyList()).forEach { element -> - apply(rule.groupValues[2], metrics.getOrPut(element) { Metrics() }) - } - } - } - document.getAllElements().forEach { element -> - element.attr("style").takeIf(String::isNotBlank)?.let { apply(it, metrics.getOrPut(element) { Metrics() }) } - } - - val boxes = linkedSetOf>() - document.getAllElements().forEach { element -> - val localName = element.tagName().substringAfter(':').lowercase(Locale.ROOT) - if (localName in setOf("text", "tspan")) { - val baseline = number(element.attr("y")) ?: return@forEach - val fontSize = number(element.attr("font-size")) ?: metrics[element]?.fontSize ?: 16f - boxes += (baseline - fontSize).roundToInt() to (baseline + fontSize * .3f).roundToInt() - return@forEach - } - if (localName !in setOf("p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "dt", "dd", "figcaption", "caption")) return@forEach - val own = metrics[element] ?: return@forEach - var top = own.top ?: return@forEach - var ancestor = element.parent() - while (ancestor != null && ancestor !== document) { - metrics[ancestor]?.top?.let { top += it } - ancestor = ancestor.parent() - } - val height = own.height ?: own.lineHeight ?: own.fontSize?.times(1.3f) ?: return@forEach - if (height > 1f) boxes += top.roundToInt() to (top + height).roundToInt() - } - return boxes.joinToString(",") { "${it.first}:${it.second}" } - } - private fun fixedViewport(document: Document, body: Element, defaultWidth: Int, defaultHeight: Int): Pair { val viewport = document.getAllElements().firstOrNull { it.tagName().substringAfter(':').equals("meta", true) && it.attr("name").equals("viewport", true) @@ -635,8 +554,7 @@ html.alex-continuous { overflow-x:hidden; overflow-y:auto; } .alex-chapter { position:relative; max-width:100%; } body.alex-fixed { box-sizing:border-box; width:100vw; height:100vh; padding:0 !important; overflow:hidden !important; touch-action:none; } body.alex-fixed .alex-chapter { display:none !important; position:absolute !important; left:0 !important; top:0 !important; max-width:none !important; max-height:none !important; margin:0 !important; padding:0 !important; overflow:visible !important; transform-origin:0 0; box-sizing:border-box; background-color:var(--alex-paper); } -body.alex-fixed .alex-chapter.alex-current-page, body.alex-fixed .alex-chapter.alex-stream-next { display:block !important; } -#alex-cut-mask { display:none; position:fixed; z-index:2147483000; left:0; right:0; bottom:0; height:0; background:var(--alex-paper); pointer-events:none; } +body.alex-fixed .alex-chapter.alex-current-page { display:block !important; } body.alex-paged .alex-chapter { break-before:column !important; } body.alex-paged .alex-chapter:first-of-type { break-before:auto !important; } body.alex-continuous .alex-chapter { break-before:auto !important; margin-bottom:3em; } @@ -661,30 +579,40 @@ mark.alex-annotation { color:inherit !important; background:#ddd !important; bor - + $content - """ + private const val IMAGE_DITHER_SCRIPT = """ +function applyImageDithering(root) { + var filter=document.getElementById('alex-image-dither'); + if(!filter)return; + if(!filter.firstChild){ + function node(name,attributes){var value=document.createElementNS('http://www.w3.org/2000/svg',name);Object.keys(attributes||{}).forEach(function(key){value.setAttribute(key,String(attributes[key]));});return value;} + filter.appendChild(node('feColorMatrix',{type:'matrix',result:'alex-gray',values:'0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0 0 0 1 0'})); + filter.appendChild(node('feTurbulence',{type:'fractalNoise',baseFrequency:'.47',numOctaves:'1',seed:'37',stitchTiles:'stitch',result:'alex-noise'})); + filter.appendChild(node('feColorMatrix',{in:'alex-noise',type:'matrix',result:'alex-drift',values:'.065 0 0 0 -.0325 .065 0 0 0 -.0325 .065 0 0 0 -.0325 0 0 0 1 0'})); + filter.appendChild(node('feComposite',{in:'alex-gray',in2:'alex-drift',operator:'arithmetic',k1:'0',k2:'1',k3:'1',k4:'0',result:'alex-noisy'})); + var levels=[];for(var level=0;level<16;level++)levels.push((level/15).toFixed(4)); + var quantized=node('feComponentTransfer',{in:'alex-noisy',result:'alex-quantized'}); + ['R','G','B'].forEach(function(channel){quantized.appendChild(node('feFunc'+channel,{type:'discrete',tableValues:levels.join(' ')}));}); + quantized.appendChild(node('feFuncA',{type:'table',tableValues:'0 1'}));filter.appendChild(quantized); + filter.appendChild(node('feComposite',{in:'alex-quantized',in2:'SourceGraphic',operator:'in'})); + } + Array.prototype.forEach.call(root.querySelectorAll('img,canvas,video,.alex-chapter svg'),function(image){image.style.filter='url(#alex-image-dither)';}); +} +""" + private const val REFLOWABLE_READER_SCRIPT = """ (function () { 'use strict'; var state = { page:0, total:1, mode:'paged', chapterStarts:[], searchIndex:-1, searchToken:0 }; - function filterNode(name,attributes){var node=document.createElementNS('http://www.w3.org/2000/svg',name);Object.keys(attributes||{}).forEach(function(key){node.setAttribute(key,String(attributes[key]));});return node;} - function filterFunctions(parent,type,values){['R','G','B'].forEach(function(channel){parent.appendChild(filterNode('feFunc'+channel,{type:type,tableValues:values}));});parent.appendChild(filterNode('feFuncA',{type:'table',tableValues:'0 1'}));} - function applyToneFilter(settings){ - var filter=document.getElementById('alex-page-filter');while(filter&&filter.firstChild)filter.removeChild(filter.firstChild); - var exponent=Math.max(1,Math.min(5,Number(settings.contrastExponent)||1)),gray=Math.max(16,Math.min(255,Number(settings.grayPoint)||255)),dither=settings.dithering||'off'; - document.body.style.filter='none';if(!filter||(Math.abs(exponent-1)<.001&&dither==='off'))return;var source='SourceGraphic'; - if(Math.abs(exponent-1)>=.001){var values=[],g=gray/255,rem=1-g;for(var i=0;i<=64;i++){var c=i/64,out=cg&&rem>0?g+rem*Math.pow((c-g)/rem,1/exponent):g);values.push(Math.max(0,Math.min(1,out)).toFixed(4));}var tone=filterNode('feComponentTransfer',{in:source,result:'alex-tone'});filterFunctions(tone,'table',values.join(' '));filter.appendChild(tone);source='alex-tone';} - if(dither!=='off'){filter.appendChild(filterNode('feColorMatrix',{in:source,type:'matrix',result:'alex-gray',values:'0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0 0 0 1 0'}));filter.appendChild(filterNode('feTurbulence',{type:'fractalNoise',baseFrequency:'.47',numOctaves:'1',seed:'37',stitchTiles:'stitch',result:'alex-noise'}));var amplitude=dither==='g2' ? 0.96 : 0.065,offset=-amplitude/2;filter.appendChild(filterNode('feColorMatrix',{in:'alex-noise',type:'matrix',result:'alex-drift',values:amplitude+' 0 0 0 '+offset+' '+amplitude+' 0 0 0 '+offset+' '+amplitude+' 0 0 0 '+offset+' 0 0 0 1 0'}));filter.appendChild(filterNode('feComposite',{in:'alex-gray',in2:'alex-drift',operator:'arithmetic',k1:'0',k2:'1',k3:'1',k4:'0',result:'alex-noisy'}));var levels=[];if(dither==='g2')levels=['0','1'];else for(var level=0;level<16;level++)levels.push((level/15).toFixed(4));var quantized=filterNode('feComponentTransfer',{in:'alex-noisy',result:'alex-quantized'});filterFunctions(quantized,'discrete',levels.join(' '));filter.appendChild(quantized);filter.appendChild(filterNode('feComposite',{in:'alex-quantized',in2:'SourceGraphic',operator:'in'}));} - document.body.style.filter='url(#alex-page-filter)'; - } var scrolling = function () { return document.scrollingElement; }; var pageExtent = function () { return state.mode === 'paged' ? window.innerWidth : window.innerHeight; }; var scrollPosition = function () { return state.mode === 'paged' ? scrolling().scrollLeft : scrolling().scrollTop; }; @@ -774,7 +702,7 @@ ${if (publication.fixedLayout) FIXED_READER_SCRIPT else REFLOWABLE_READER_SCRIPT body.classList.toggle('alex-font-override', family !== 'Publisher'); ['left','right','center','justify'].forEach(function (name) { body.classList.toggle('alex-align-' + name, settings.textAlign === name); }); document.getElementById('alex-publisher-styles').disabled = settings.publisherStyles === false; - applyToneFilter(settings); + applyImageDithering(body); setTimeout(function () { recalculate(); if (keep) goToLocator(keep); }, 80); } function recordNavigation(targetPage, origin) { @@ -892,220 +820,27 @@ ${if (publication.fixedLayout) FIXED_READER_SCRIPT else REFLOWABLE_READER_SCRIPT (function () { 'use strict'; var pages = Array.prototype.slice.call(document.querySelectorAll('.alex-chapter')); - var state = { - page:0, total:pages.length, settings:{}, offsetX:NaN, offsetY:NaN, - scale:1, crop:{x:0,y:0,width:1,height:1}, nextVisible:0, searchIndex:-1, lineCache:[] - }; - function number(value, fallback) { value=Number(value); return isFinite(value)?value:fallback; } - function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } - function pageElement() { return pages[clamp(state.page,0,pages.length-1)]; } - function pageSize(page) { - return { width:Math.max(1,number(page.getAttribute('data-page-width'),1200)), - height:Math.max(1,number(page.getAttribute('data-page-height'),1600)) }; - } - function normalizedMargins(value) { - value=value||{}; - var left=clamp(number(value.left,0),0,90), right=clamp(number(value.right,0),0,90); - var top=clamp(number(value.top,0),0,90), bottom=clamp(number(value.bottom,0),0,90); - if(left+right>95){var hx=95/(left+right);left*=hx;right*=hx;} - if(top+bottom>95){var hy=95/(top+bottom);top*=hy;bottom*=hy;} - return {left:left,top:top,right:right,bottom:bottom}; - } - function marginsForPage(index) { - var settings=state.settings||{}, scheme=settings.cropScheme||'none'; - if(scheme==='none') return normalizedMargins(null); - if(scheme==='even-odd') return normalizedMargins(((index+1)%2===0)?settings.cropEven:settings.cropOdd); - return normalizedMargins(settings.cropAny); - } - function sourceCrop(page,index) { - var size=pageSize(page), margins=marginsForPage(index); - var x=size.width*margins.left/100, y=size.height*margins.top/100; - return {x:x,y:y,width:Math.max(1,size.width*(100-margins.left-margins.right)/100), - height:Math.max(1,size.height*(100-margins.top-margins.bottom)/100),size:size}; - } - function centeredOffset(start,length,visible) { - return length<=visible ? start-(visible-length)/2 : start; - } - function clampOffset(value,start,length,visible) { - if(length<=visible) return centeredOffset(start,length,visible); - return clamp(number(value,start),start,start+length-visible); - } - function svgNode(name, attributes) { - var node=document.createElementNS('http://www.w3.org/2000/svg',name); - Object.keys(attributes||{}).forEach(function(key){node.setAttribute(key,String(attributes[key]));}); - return node; - } - function appendFunctions(parent, type, values) { - ['R','G','B'].forEach(function(channel){parent.appendChild(svgNode('feFunc'+channel,{type:type,tableValues:values}));}); - parent.appendChild(svgNode('feFuncA',{type:'table',tableValues:'0 1'})); - } - function toneValues(exponent, gray) { - var values=[], g=clamp(gray/255,0.001,1), remaining=1-g; - for(var i=0;i<=64;i++){ - var c=i/64, out; - if(cg && remaining>0) out=g+remaining*Math.pow((c-g)/remaining,1/exponent); - else out=g; - values.push(clamp(out,0,1).toFixed(4)); - } - return values.join(' '); - } - function updateFilter(page) { - var settings=state.settings||{}, exponent=clamp(number(settings.contrastExponent,1),1,5); - var gray=clamp(number(settings.grayPoint,255),16,255), dither=settings.dithering||'off'; - var filter=document.getElementById('alex-page-filter'); - while(filter && filter.firstChild) filter.removeChild(filter.firstChild); - pages.forEach(function(other){other.style.filter='none';}); - if(!filter || (Math.abs(exponent-1)<0.001 && dither==='off')) return; - var source='SourceGraphic'; - if(Math.abs(exponent-1)>=0.001){ - var tone=svgNode('feComponentTransfer',{in:source,result:'alex-tone'}); - appendFunctions(tone,'table',toneValues(exponent,gray)); filter.appendChild(tone); source='alex-tone'; - } - if(dither!=='off'){ - filter.appendChild(svgNode('feColorMatrix',{in:source,type:'matrix',result:'alex-gray', - values:'0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0 0 0 1 0'})); - filter.appendChild(svgNode('feTurbulence',{type:'fractalNoise',baseFrequency:'0.47',numOctaves:'1',seed:'37',stitchTiles:'stitch',result:'alex-noise'})); - var amplitude=dither==='g2'?0.96:0.065, offset=-amplitude/2; - filter.appendChild(svgNode('feColorMatrix',{in:'alex-noise',type:'matrix',result:'alex-drift', - values:amplitude+' 0 0 0 '+offset+' '+amplitude+' 0 0 0 '+offset+' '+amplitude+' 0 0 0 '+offset+' 0 0 0 1 0'})); - filter.appendChild(svgNode('feComposite',{in:'alex-gray',in2:'alex-drift',operator:'arithmetic',k1:'0',k2:'1',k3:'1',k4:'0',result:'alex-noisy'})); - var levels=[]; if(dither==='g2') levels=['0','1']; else for(var level=0;level<16;level++) levels.push((level/15).toFixed(4)); - var quantized=svgNode('feComponentTransfer',{in:'alex-noisy',result:'alex-quantized'}); - appendFunctions(quantized,'discrete',levels.join(' ')); filter.appendChild(quantized); - filter.appendChild(svgNode('feComposite',{in:'alex-quantized',in2:'SourceGraphic',operator:'in'})); - } - page.style.filter='url(#alex-page-filter)'; - } - function layoutPage() { - showCutMask(0,1); + var state = {page:0,total:pages.length,searchIndex:-1}; + function number(value,fallback){value=Number(value);return isFinite(value)?value:fallback;} + function clamp(value,min,max){return Math.max(min,Math.min(max,value));} + function pageElement(){return pages[clamp(state.page,0,pages.length-1)];} + function pageSize(page){return{width:Math.max(1,number(page.getAttribute('data-page-width'),1200)),height:Math.max(1,number(page.getAttribute('data-page-height'),1600))};} + function layoutPage(){ state.page=clamp(Math.round(number(state.page,0)),0,pages.length-1); - pages.forEach(function(page,index){page.classList.toggle('alex-current-page',index===state.page);page.classList.remove('alex-stream-next');}); - var page=pageElement(), crop=sourceCrop(page,state.page), size=crop.size; - page.style.width=size.width+'px'; page.style.height=size.height+'px'; - var availableWidth=Math.max(1,window.innerWidth), availableHeight=Math.max(1,window.innerHeight); - var fitPage=Math.min(availableWidth/crop.width,availableHeight/crop.height); - var fitWidth=availableWidth/crop.width, mode=state.settings.zoomMode||'fit-page'; - var scale=mode==='fit-width'?fitWidth:(mode==='custom'?fitPage*clamp(number(state.settings.customZoom,100),50,400)/100:fitPage); - scale=Math.max(0.01,scale); var visibleWidth=availableWidth/scale, visibleHeight=availableHeight/scale; - if(mode==='fit-page'){ - state.offsetX=centeredOffset(crop.x,crop.width,visibleWidth); - state.offsetY=centeredOffset(crop.y,crop.height,visibleHeight); - } else if(mode==='fit-width') { - state.offsetX=clampOffset(state.offsetX,crop.x,crop.width,visibleWidth); - state.offsetY=clamp(number(state.offsetY,crop.y),crop.y,crop.y+crop.height-1); - } else { - state.offsetX=clampOffset(state.offsetX,crop.x,crop.width,visibleWidth); - state.offsetY=clampOffset(state.offsetY,crop.y,crop.height,visibleHeight); - } - page.style.transform='translate('+(-state.offsetX*scale)+'px,'+(-state.offsetY*scale)+'px) scale('+scale+')'; - state.scale=scale; state.crop=crop; state.nextVisible=0; - updateFilter(page); - if(mode==='fit-width' && (state.settings.scrollMode||'screen')==='screen'){ - var currentTarget=state.offsetY+visibleHeight,currentEnd=crop.y+crop.height; - if(currentTargetline.top;}); - state.lineCache[index]=lines;return lines; - } - function lineCutOnPage(page,target,direction,scale,offset) { - if((state.settings.scrollMode||'screen')!=='screen') return target; - var lines=measuredLines(page); - for(var i=0;iline.top-2&&target0) return line.top>offset+8?line.top:line.bottom; - return line.top; - } - } - return target; - } - function linePreservingCut(target,direction) {return lineCutOnPage(pageElement(),target,direction,state.scale,state.offsetY);} - function showCutMask(sourceHeight,scale) { - var mask=document.getElementById('alex-cut-mask'),height=Math.max(0,sourceHeight*scale); - if(!mask||height<1){if(mask)mask.style.display='none';return;} - mask.style.height=Math.ceil(height)+'px';mask.style.display='block'; - } - function advanceSlice(direction) { - if((state.settings.zoomMode||'fit-page')!=='fit-width'){setPage(state.page+direction,true);return;} - var availableHeight=window.innerHeight,visibleHeight=availableHeight/state.scale,crop=state.crop,start=crop.y,end=crop.y+crop.height; - if(direction>0){ - if(state.offsetY+visibleHeight0){state.offsetY=state.crop.y+alreadyShown;layoutPage();} - notifyPage(); - } - return; - } - var precedingScreen=(state.offsetY-start)*state.scale; - if(precedingScreen>=availableHeight-1){ - state.offsetY=clamp(linePreservingCut(Math.max(start,state.offsetY-visibleHeight),-1),start,state.offsetY-1);layoutPage();notifyPage();return; - } - if(state.page>0){ - var currentPreceding=Math.max(0,precedingScreen),needed=(state.settings.scrollMode||'screen')==='screen'?availableHeight-currentPreceding:availableHeight; - state.page-=1;state.offsetX=NaN;state.offsetY=NaN;layoutPage(); - state.offsetY=clamp(linePreservingCut(state.crop.y+state.crop.height-needed/state.scale,-1),state.crop.y,state.crop.y+state.crop.height-1); - layoutPage();notifyPage();return; - } - if(state.offsetY>start){state.offsetY=start;layoutPage();notifyPage();} - } - function goToLocator(value) { - if(!value){setPage(0,true);return;} - state.page=clamp(Math.round(number(value.chapter,value.page||0)),0,state.total-1); - state.offsetX=number(value.viewportX,NaN);state.offsetY=number(value.viewportY,NaN);layoutPage();notifyPage(); - } - function applySettings(settings,keep) { - state.settings=settings||{}; - // Fixed-layout coordinates depend on the publisher stylesheet. - document.getElementById('alex-publisher-styles').disabled=false; - if(keep){state.page=clamp(Math.round(number(keep.chapter,keep.page||0)),0,state.total-1);state.offsetX=number(keep.viewportX,NaN);state.offsetY=number(keep.viewportY,NaN);} - layoutPage();notifyPage(); + pages.forEach(function(page,index){page.classList.toggle('alex-current-page',index===state.page);}); + var page=pageElement(),size=pageSize(page),availableWidth=Math.max(1,window.innerWidth),availableHeight=Math.max(1,window.innerHeight); + var scale=Math.max(0.01,Math.min(availableWidth/size.width,availableHeight/size.height)); + var x=(availableWidth-size.width*scale)/2,y=(availableHeight-size.height*scale)/2; + page.style.width=size.width+'px';page.style.height=size.height+'px';page.style.transform='translate('+x+'px,'+y+'px) scale('+scale+')'; + applyImageDithering(page); } + function locator(){return{page:state.page,totalPages:state.total,chapter:state.page,offset:0,progress:state.total<=1?0:state.page/(state.total-1)};} + function notifyPage(){if(window.Android&&Android.onPageChanged)Android.onPageChanged(JSON.stringify(locator()));} + function recalculate(){state.total=pages.length;layoutPage();notifyPage();} + function setPage(page,report){state.page=clamp(Math.round(number(page,0)),0,state.total-1);layoutPage();if(report!==false)notifyPage();} + function pageForElement(element){var chapter=element?element.closest('.alex-chapter'):null;return chapter?clamp(number(chapter.getAttribute('data-spine'),0),0,state.total-1):0;} + function goToLocator(value){if(!value){setPage(0,true);return;}setPage(number(value.chapter,value.page||0),true);} + function applySettings(settings,keep){document.getElementById('alex-publisher-styles').disabled=false;if(keep)state.page=clamp(Math.round(number(keep.chapter,keep.page||0)),0,state.total-1);layoutPage();notifyPage();} function recordNavigation(targetPage,origin) { var from=origin||locator(),target=clamp(Math.round(number(targetPage,0)),0,state.total-1),fromPage=clamp(Math.round(number(from.page,0)),0,state.total-1); if(target===fromPage||!window.Android||!Android.onNavigationJump)return;Android.onNavigationJump(JSON.stringify(from)); @@ -1149,7 +884,6 @@ ${if (publication.fixedLayout) FIXED_READER_SCRIPT else REFLOWABLE_READER_SCRIPT var before=document.createRange();before.selectNodeContents(chapter);before.setEnd(range.startContainer,range.startOffset);var start=before.toString().length,quote=range.toString(); return{chapter:parseInt(chapter.getAttribute('data-spine')||'0'),start:start,end:start+quote.length,quote:quote.substring(0,10000)}; } - function nodeAtOffset(chapter,offset){var walker=document.createTreeWalker(chapter,NodeFilter.SHOW_TEXT),node,count=0,last=null;while((node=walker.nextNode())){last=node;if(count+node.data.length>=offset)return{node:node,offset:Math.max(0,offset-count)};count+=node.data.length;}return last?{node:last,offset:last.data.length}:null;} function wrapAnnotation(annotation) { var chapter=document.querySelector('.alex-chapter[data-spine="'+annotation.chapter+'"]');if(!chapter||annotation.end<=annotation.start)return; var walker=document.createTreeWalker(chapter,NodeFilter.SHOW_TEXT,{acceptNode:function(node){return node.parentElement&&node.parentElement.closest('mark.alex-annotation')?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT;}}),nodes=[],node,count=0; @@ -1157,27 +891,15 @@ ${if (publication.fixedLayout) FIXED_READER_SCRIPT else REFLOWABLE_READER_SCRIPT } function applyAnnotations(values){(values||[]).slice().sort(function(a,b){return b.start-a.start;}).forEach(wrapAnnotation);} function removeAnnotation(id){Array.prototype.forEach.call(document.querySelectorAll('mark[data-annotation="'+id+'"]'),function(mark){mark.replaceWith(document.createTextNode(mark.textContent));});document.body.normalize();} - function panBy(dx,dy){ - if((state.settings.zoomMode||'fit-page')!=='custom')return;state.offsetX-=number(dx,0)/state.scale;state.offsetY-=number(dy,0)/state.scale;layoutPage(); - } - function zoomBy(factor){ - factor=clamp(number(factor,1),0.75,1.33);var crop=state.crop,fitPage=Math.min(window.innerWidth/crop.width,window.innerHeight/crop.height); - if((state.settings.zoomMode||'fit-page')!=='custom')state.settings.customZoom=clamp(Math.round(100*state.scale/fitPage),50,400); - var centerX=state.offsetX+window.innerWidth/(2*state.scale),centerY=state.offsetY+window.innerHeight/(2*state.scale); - state.settings.zoomMode='custom';state.settings.customZoom=clamp(number(state.settings.customZoom,100)*factor,50,400); - var nextScale=fitPage*state.settings.customZoom/100;state.offsetX=centerX-window.innerWidth/(2*nextScale);state.offsetY=centerY-window.innerHeight/(2*nextScale);layoutPage(); - } - function finishViewportGesture(){notifyPage();if(window.Android&&Android.onViewportChanged)Android.onViewportChanged(JSON.stringify({zoomMode:state.settings.zoomMode||'fit-page',customZoom:Math.round(number(state.settings.customZoom,100))}));} document.addEventListener('click',function(event){ var mark=event.target.closest('mark.alex-annotation');if(mark&&window.Android){event.preventDefault();Android.onAnnotationTapped(mark.dataset.annotation);return;} var link=event.target.closest('a');if(!link)return;var external=link.getAttribute('data-external-href');if(external){event.preventDefault();if(window.Android)Android.onExternalLink(external);return;} var reference=link.getAttribute('data-alex-ref');if(reference){event.preventDefault();goToReference(reference,true);} },true); var resizeTimer=0;window.addEventListener('resize',function(){clearTimeout(resizeTimer);resizeTimer=setTimeout(function(){layoutPage();notifyPage();},120);}); - window.Alex={setPage:setPage,next:function(){advanceSlice(1);},previous:function(){advanceSlice(-1);},locator:locator,goToLocator:goToLocator, + window.Alex={setPage:setPage,next:function(){setPage(state.page+1,true);},previous:function(){setPage(state.page-1,true);},locator:locator,goToLocator:goToLocator, applySettings:applySettings,goToReference:goToReference,search:search,clearSearch:clearSearch,moveSearch:moveSearch,captureSelection:captureSelection, - applyAnnotations:applyAnnotations,wrapAnnotation:wrapAnnotation,removeAnnotation:removeAnnotation,chapterPage:function(index){return clamp(index,0,state.total-1);}, - panBy:panBy,zoomBy:zoomBy,finishViewportGesture:finishViewportGesture}; + applyAnnotations:applyAnnotations,wrapAnnotation:wrapAnnotation,removeAnnotation:removeAnnotation,chapterPage:function(index){return clamp(index,0,state.total-1);}}; function ready(){recalculate();if(window.Android&&Android.onReaderReady)Android.onReaderReady();} document.fonts.ready.then(function(){setTimeout(ready,30);}); })(); diff --git a/app/src/main/java/com/alexandria/reader/Models.kt b/app/src/main/java/com/alexandria/reader/Models.kt index a66ba2b..da26794 100644 --- a/app/src/main/java/com/alexandria/reader/Models.kt +++ b/app/src/main/java/com/alexandria/reader/Models.kt @@ -84,8 +84,6 @@ data class ReaderLocation( val chapter: Int = 0, val offset: Int = 0, val progress: Float = 0f, - val viewportX: Float = 0f, - val viewportY: Float = 0f, ) { fun toJson(): JSONObject = JSONObject().apply { put("page", page) @@ -93,8 +91,6 @@ data class ReaderLocation( put("chapter", chapter) put("offset", offset) put("progress", progress.toDouble()) - put("viewportX", viewportX.toDouble()) - put("viewportY", viewportY.toDouble()) } companion object { @@ -104,32 +100,10 @@ data class ReaderLocation( chapter = value.optInt("chapter").coerceAtLeast(0), offset = value.optInt("offset").coerceAtLeast(0), progress = value.optDouble("progress", 0.0).toFloat().coerceIn(0f, 1f), - viewportX = value.optDouble("viewportX", 0.0).toFloat(), - viewportY = value.optDouble("viewportY", 0.0).toFloat(), ) } } -data class CropMargins( - val left: Float = 0f, - val top: Float = 0f, - val right: Float = 0f, - val bottom: Float = 0f, -) { - fun toJson(): JSONObject = JSONObject().apply { - put("left", left.toDouble()); put("top", top.toDouble()); put("right", right.toDouble()); put("bottom", bottom.toDouble()) - } - - companion object { - fun fromJson(value: JSONObject?): CropMargins { - if (value == null) return CropMargins() - fun component(name: String): Float = value.optDouble(name, 0.0).toFloat() - .takeIf(Float::isFinite)?.coerceIn(0f, 90f) ?: 0f - return CropMargins(component("left"), component("top"), component("right"), component("bottom")) - } - } -} - data class ReaderSettings( val fontFamily: String = ATKINSON_HYPERLEGIBLE_NEXT, val fontSize: Int = 20, @@ -140,16 +114,6 @@ data class ReaderSettings( val hyphenation: Boolean = true, val mode: String = "paged", val brightness: Int = -1, - val zoomMode: String = "fit-page", - val customZoom: Int = 100, - val scrollMode: String = "screen", - val cropScheme: String = "none", - val cropAny: CropMargins = CropMargins(), - val cropEven: CropMargins = CropMargins(), - val cropOdd: CropMargins = CropMargins(), - val contrastExponent: Float = 1f, - val grayPoint: Int = 255, - val dithering: String = "off", ) { fun toJson(): JSONObject = JSONObject().apply { put("fontFamily", fontFamily) @@ -161,16 +125,6 @@ data class ReaderSettings( put("hyphenation", hyphenation) put("mode", mode) put("brightness", brightness) - put("zoomMode", zoomMode) - put("customZoom", customZoom) - put("scrollMode", scrollMode) - put("cropScheme", cropScheme) - put("cropAny", cropAny.toJson()) - put("cropEven", cropEven.toJson()) - put("cropOdd", cropOdd.toJson()) - put("contrastExponent", contrastExponent.toDouble()) - put("grayPoint", grayPoint) - put("dithering", dithering) } companion object { @@ -192,17 +146,6 @@ data class ReaderSettings( hyphenation = value.optBoolean("hyphenation", true), mode = value.optString("mode", "paged").takeIf { it in setOf("paged", "continuous") } ?: "paged", brightness = value.optInt("brightness", -1).coerceIn(-1, 100), - zoomMode = value.optString("zoomMode", "fit-page") - .takeIf { it in setOf("fit-page", "fit-width", "custom") } ?: "fit-page", - customZoom = value.optInt("customZoom", 100).coerceIn(50, 400), - scrollMode = value.optString("scrollMode", "screen").takeIf { it in setOf("screen", "page") } ?: "screen", - cropScheme = value.optString("cropScheme", "none").takeIf { it in setOf("none", "any", "even-odd") } ?: "none", - cropAny = CropMargins.fromJson(value.optJSONObject("cropAny")), - cropEven = CropMargins.fromJson(value.optJSONObject("cropEven")), - cropOdd = CropMargins.fromJson(value.optJSONObject("cropOdd")), - contrastExponent = value.optDouble("contrastExponent", 1.0).toFloat().coerceIn(1f, 5f), - grayPoint = value.optInt("grayPoint", 255).coerceIn(16, 255), - dithering = value.optString("dithering", "off").takeIf { it in setOf("off", "g16", "g2") } ?: "off", ) } } diff --git a/app/src/main/java/com/alexandria/reader/ReaderScreen.kt b/app/src/main/java/com/alexandria/reader/ReaderScreen.kt index 26b2ee5..d64bb23 100644 --- a/app/src/main/java/com/alexandria/reader/ReaderScreen.kt +++ b/app/src/main/java/com/alexandria/reader/ReaderScreen.kt @@ -7,9 +7,6 @@ import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.pm.ActivityInfo -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.RectF import android.graphics.Typeface import android.net.Uri import android.os.Handler @@ -29,8 +26,6 @@ import android.widget.EditText import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.PopupMenu -import android.widget.RadioButton -import android.widget.RadioGroup import android.widget.ScrollView import android.widget.SeekBar import android.widget.Spinner @@ -79,7 +74,6 @@ class ReaderScreen( isFocusableInTouchMode = true buildReader() webView.listener = this - webView.configurePageTools(publication.fixedLayout, settings) webView.load(repository.readerFile(publication)) repository.markOpened(publication.book.id) applyBrightness() @@ -211,17 +205,6 @@ class ReaderScreen( } override fun onTap(x: Float, y: Float) { - if (publication.fixedLayout && settings.zoomMode == "custom") { - val centerX = webView.width / 2f - val centerY = webView.height / 2f - if (x in webView.width * .28f..webView.width * .72f && y in webView.height * .28f..webView.height * .72f) { - toggleControls() - } else { - webView.panBy((centerX - x) * .72f, (centerY - y) * .72f) - webView.finishViewportGesture() - } - return - } val cornerX = webView.width * .18f val cornerY = webView.height * .18f when { @@ -240,16 +223,6 @@ class ReaderScreen( if (effectiveForward) nextPage() else previousPage() } - override fun onVerticalSwipe(forward: Boolean) { - if (forward) nextPage() else previousPage() - } - - override fun onViewportChanged(zoomMode: String, customZoom: Int) { - settings = settings.copy(zoomMode = zoomMode, customZoom = customZoom) - webView.configurePageTools(publication.fixedLayout, settings) - repository.saveSettings(publication.book.id, settings) - } - private fun nextPage() = webView.nextPage() private fun previousPage() = webView.previousPage() @@ -269,10 +242,7 @@ class ReaderScreen( private fun rememberLocation(location: ReaderLocation = current) { val previous = locationHistory.lastOrNull() - val samePosition = previous != null && previous.page == location.page && previous.chapter == location.chapter && - (!publication.fixedLayout || - (kotlin.math.abs(previous.viewportX - location.viewportX) < 1f && - kotlin.math.abs(previous.viewportY - location.viewportY) < 1f)) + val samePosition = previous != null && previous.page == location.page && previous.chapter == location.chapter if (!samePosition) locationHistory.addLast(location) while (locationHistory.size > 40) locationHistory.removeFirst() } @@ -295,8 +265,6 @@ class ReaderScreen( menu.add("Bookmarks").setOnMenuItemClickListener { showBookmarks(); true } menu.add("Annotations and highlights").setOnMenuItemClickListener { showAnnotations(); true } menu.add("Go to page").setOnMenuItemClickListener { showGoToPage(); true } - menu.add("Reading appearance").setOnMenuItemClickListener { showAppearance(); true } - menu.add("Page display tools").setOnMenuItemClickListener { showPageDisplay(); true } menu.add("Rotate screen").setOnMenuItemClickListener { rotateScreen(); true } menu.add("Book information").setOnMenuItemClickListener { showBookInformation(); true } menu.add("Close book").setOnMenuItemClickListener { flush(); closeReader(); true } @@ -660,142 +628,6 @@ class ReaderScreen( .setNegativeButton("Reset") { _, _ -> updateSettings(ReaderSettings()) }.show() } - private fun showPageDisplay() { - val content = LinearLayout(activity).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(24), dp(8), dp(24), dp(20)) - } - fun heading(value: String) = TextView(activity).apply { - text = value; textSize = 13f; setTextColor(EInkPalette.MUTED_INK); setPadding(0, dp(14), 0, dp(3)) - } - fun spinner(label: String, choices: List, selected: Int, change: (Int) -> Unit) { - content.addView(heading(label)) - content.addView(Spinner(activity).apply { - adapter = ArrayAdapter(activity, android.R.layout.simple_spinner_dropdown_item, choices) - setSelection(selected.coerceIn(0, choices.lastIndex), false) - onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener { - var previous = selected.coerceIn(0, choices.lastIndex) - override fun onNothingSelected(parent: android.widget.AdapterView<*>?) = Unit - override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) { - if (position != previous) { previous = position; change(position) } - } - } - }, LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, dp(48))) - } - fun slider(label: String, max: Int, value: Int, format: (Int) -> String, change: (Int) -> Unit) { - val title = heading("$label: ${format(value)}") - content.addView(title) - content.addView(SeekBar(activity).apply { - this.max = max; progress = value.coerceIn(0, max) - setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { - override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit - override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { - if (fromUser) title.text = "$label: ${format(progress)}" - } - override fun onStopTrackingTouch(seekBar: SeekBar) = change(seekBar.progress) - }) - }) - } - fun cropButton(label: String, margins: () -> CropMargins, save: (CropMargins) -> Unit) { - content.addView(TextView(activity).apply { - text = label - textSize = 16f - gravity = Gravity.CENTER_VERTICAL - setPadding(dp(14), dp(12), dp(14), dp(12)) - setTextColor(EInkPalette.INK) - setBackgroundColor(EInkPalette.SURFACE) - setOnClickListener { showCropEditor(label, margins(), save) } - }, LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { topMargin = dp(7) }) - } - - if (!publication.fixedLayout) { - content.addView(TextView(activity).apply { - text = "This EPUB is reflowable. Crop, zoom, pan, and page-cut controls become active for a pre-paginated EPUB. Tone and dithering controls remain available." - textSize = 14f; setTextColor(EInkPalette.MUTED_INK); setPadding(0, dp(8), 0, dp(8)) - }) - } else { - val zoomValues = listOf("fit-page", "fit-width", "custom") - spinner("Zoom mode", listOf("Fit to page", "Fit to width", "Custom"), zoomValues.indexOf(settings.zoomMode)) { - updateSettings(settings.copy(zoomMode = zoomValues[it])) - } - slider("Custom zoom", 350, settings.customZoom - 50, { "${it + 50}%" }) { - updateSettings(settings.copy(zoomMode = "custom", customZoom = it + 50)) - } - spinner("Fit-to-width scrolling", listOf("Continuous screen", "Current page"), if (settings.scrollMode == "page") 1 else 0) { - updateSettings(settings.copy(scrollMode = if (it == 1) "page" else "screen")) - } - val cropValues = listOf("none", "any", "even-odd") - spinner("Margin cropping", listOf("None", "Same margins on all pages", "Separate even and odd pages"), cropValues.indexOf(settings.cropScheme)) { - updateSettings(settings.copy(cropScheme = cropValues[it])) - } - cropButton("Edit shared crop margins", { settings.cropAny }) { updateSettings(settings.copy(cropAny = it)) } - cropButton("Edit even-page crop margins", { settings.cropEven }) { updateSettings(settings.copy(cropEven = it)) } - cropButton("Edit odd-page crop margins", { settings.cropOdd }) { updateSettings(settings.copy(cropOdd = it)) } - } - - slider("Contrast exponent", 40, ((settings.contrastExponent - 1f) * 10).roundToInt(), - { String.format(java.util.Locale.getDefault(), "%.1f", 1f + it / 10f) }) { - updateSettings(settings.copy(contrastExponent = 1f + it / 10f)) - } - slider("Gray point", 239, settings.grayPoint - 16, { "${it + 16}" }) { - updateSettings(settings.copy(grayPoint = it + 16)) - } - val ditherValues = listOf("off", "g16", "g2") - spinner("Dithering", listOf("Off", "16-level grayscale", "Black and white"), ditherValues.indexOf(settings.dithering)) { - updateSettings(settings.copy(dithering = ditherValues[it])) - } - - val scroll = ScrollView(activity).apply { addView(content) } - AlertDialog.Builder(activity).setTitle("Page display tools").setView(scroll) - .setPositiveButton("Done", null) - .setNegativeButton("Reset") { _, _ -> - updateSettings(settings.copy( - zoomMode = "fit-page", customZoom = 100, scrollMode = "screen", cropScheme = "none", - cropAny = CropMargins(), cropEven = CropMargins(), cropOdd = CropMargins(), - contrastExponent = 1f, grayPoint = 255, dithering = "off", - )) - }.show() - } - - private fun showCropEditor(title: String, initial: CropMargins, save: (CropMargins) -> Unit) { - var value = initial - val content = LinearLayout(activity).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(24), dp(14), dp(24), dp(12)) - } - val preview = CropPreview(activity).apply { margins = value } - content.addView(preview, LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, dp(230))) - fun edge(label: String, initialValue: Float, update: (Float) -> CropMargins) { - fun format(value: Float) = String.format(java.util.Locale.getDefault(), "%.1f%%", value) - val caption = TextView(activity).apply { - text = "$label: ${format(initialValue)}"; textSize = 13f; setTextColor(EInkPalette.MUTED_INK); setPadding(0, dp(8), 0, 0) - } - content.addView(caption) - content.addView(SeekBar(activity).apply { - max = 900; progress = (initialValue * 10).roundToInt() - setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { - override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit - override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { - if (!fromUser) return - val percentage = progress / 10f - caption.text = "$label: ${format(percentage)}" - value = update(percentage) - preview.margins = value - } - override fun onStopTrackingTouch(seekBar: SeekBar?) = Unit - }) - }) - } - edge("Left", value.left) { value.copy(left = it) } - edge("Top", value.top) { value.copy(top = it) } - edge("Right", value.right) { value.copy(right = it) } - edge("Bottom", value.bottom) { value.copy(bottom = it) } - AlertDialog.Builder(activity).setTitle(title).setView(ScrollView(activity).apply { addView(content) }) - .setPositiveButton("Apply") { _, _ -> save(value) } - .setNeutralButton("Clear") { _, _ -> save(CropMargins()) } - .setNegativeButton("Cancel", null).show() - } - private fun updateSettings(value: ReaderSettings, reflow: Boolean = true) { if (value == settings) return if (reflow && initialized) { @@ -803,13 +635,11 @@ class ReaderScreen( settings = value repository.saveSettings(publication.book.id, settings) applyBrightness() - webView.configurePageTools(publication.fixedLayout, settings) webView.applySettings(settings, location) } } else { settings = value repository.saveSettings(publication.book.id, settings) - webView.configurePageTools(publication.fixedLayout, settings) applyBrightness() } } @@ -908,40 +738,4 @@ class ReaderScreen( private fun lp(width: Int): LinearLayout.LayoutParams = LinearLayout.LayoutParams(width, LayoutParams.MATCH_PARENT) private fun dp(value: Int): Int = (value * resources.displayMetrics.density).roundToInt() - private class CropPreview(context: Context) : View(context) { - private val paint = Paint(Paint.ANTI_ALIAS_FLAG) - private val pageBounds = RectF() - private val cropBounds = RectF() - var margins: CropMargins = CropMargins() - set(value) { field = value; invalidate() } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - val pad = width.coerceAtMost(height) * .08f - val pageRatio = 3f / 4f - val availableWidth = width - 2 * pad - val availableHeight = height - 2 * pad - val pageWidth = minOf(availableWidth, availableHeight * pageRatio) - val pageHeight = pageWidth / pageRatio - pageBounds.set( - (width - pageWidth) / 2f, - (height - pageHeight) / 2f, - (width + pageWidth) / 2f, - (height + pageHeight) / 2f, - ) - paint.style = Paint.Style.FILL; paint.color = EInkPalette.PAGE_PREVIEW; canvas.drawRect(pageBounds, paint) - val horizontalScale = if (margins.left + margins.right > 95f) 95f / (margins.left + margins.right) else 1f - val verticalScale = if (margins.top + margins.bottom > 95f) 95f / (margins.top + margins.bottom) else 1f - cropBounds.set( - pageBounds.left + pageBounds.width() * margins.left * horizontalScale / 100f, - pageBounds.top + pageBounds.height() * margins.top * verticalScale / 100f, - pageBounds.right - pageBounds.width() * margins.right * horizontalScale / 100f, - pageBounds.bottom - pageBounds.height() * margins.bottom * verticalScale / 100f, - ) - paint.color = EInkPalette.PAPER; canvas.drawRect(cropBounds, paint) - paint.style = Paint.Style.STROKE; paint.strokeWidth = resources.displayMetrics.density * 2f - paint.color = EInkPalette.INK; canvas.drawRect(cropBounds, paint) - } - } - } diff --git a/app/src/main/java/com/alexandria/reader/ReaderWebView.kt b/app/src/main/java/com/alexandria/reader/ReaderWebView.kt index bfe7ed4..4542eec 100644 --- a/app/src/main/java/com/alexandria/reader/ReaderWebView.kt +++ b/app/src/main/java/com/alexandria/reader/ReaderWebView.kt @@ -10,7 +10,6 @@ import android.view.GestureDetector import android.view.Menu import android.view.MenuItem import android.view.MotionEvent -import android.view.ScaleGestureDetector import android.webkit.ConsoleMessage import android.webkit.JavascriptInterface import android.webkit.JsResult @@ -35,8 +34,6 @@ class ReaderWebView(context: Context) : WebView(context) { fun onLocationChanged(location: ReaderLocation) fun onTap(x: Float, y: Float) fun onSwipe(forward: Boolean) - fun onVerticalSwipe(forward: Boolean) - fun onViewportChanged(zoomMode: String, customZoom: Int) fun onExternalLink(url: String) fun onNavigationJump(origin: ReaderLocation) fun onSearchResults(results: JSONArray) @@ -55,25 +52,6 @@ class ReaderWebView(context: Context) : WebView(context) { private val mainHandler = Handler(Looper.getMainLooper()) private var pageLoaded = false private var suppressTapUntil = 0L - private var fixedLayout = false - private var fixedZoomMode = "fit-page" - private var viewportDirty = false - - private val scaleGestures = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() { - override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = fixedLayout - - override fun onScale(detector: ScaleGestureDetector): Boolean { - if (!fixedLayout) return false - viewportDirty = true - customZoomBy(detector.scaleFactor) - return true - } - - override fun onScaleEnd(detector: ScaleGestureDetector) { - if (fixedLayout) finishViewportGesture() - viewportDirty = false - } - }) private val gestures = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { override fun onDown(event: MotionEvent): Boolean = true @@ -95,28 +73,15 @@ class ReaderWebView(context: Context) : WebView(context) { return false } - override fun onScroll(first: MotionEvent?, second: MotionEvent, distanceX: Float, distanceY: Float): Boolean { - if (!fixedLayout || fixedZoomMode != "custom" || scaleGestures.isInProgress) return false - viewportDirty = true - // GestureDetector reports viewport movement. The document follows the finger. - panBy(-distanceX, -distanceY) - return true - } - override fun onFling(first: MotionEvent?, second: MotionEvent, velocityX: Float, velocityY: Float): Boolean { if (first == null) return false val dx = second.x - first.x val dy = second.y - first.y - if (fixedLayout && fixedZoomMode == "custom") return true if (abs(dx) > width * .16f && abs(dx) > abs(dy) * 1.25f && abs(velocityX) > 250f) { // A westward swipe advances, matching Plato's reader gesture. listener?.onSwipe(dx < 0f) return true } - if (fixedLayout && abs(dy) > height * .12f && abs(dy) > abs(dx) * 1.25f && abs(velocityY) > 250f) { - listener?.onVerticalSwipe(dy < 0f) - return true - } return false } }) @@ -187,25 +152,16 @@ class ReaderWebView(context: Context) : WebView(context) { } fun applySettings(settings: ReaderSettings, location: ReaderLocation? = null) { - fixedZoomMode = settings.zoomMode val keep = location?.toJson()?.toString() ?: "Alex.locator()" javascript("Alex.applySettings(${settings.toJson()}, $keep)") } - fun configurePageTools(isFixedLayout: Boolean, settings: ReaderSettings) { - fixedLayout = isFixedLayout - fixedZoomMode = settings.zoomMode - } - fun goToPage(page: Int) = javascript("Alex.setPage($page, true)") fun nextPage() = javascript("Alex.next()") fun previousPage() = javascript("Alex.previous()") fun goToLocation(location: ReaderLocation) = javascript("Alex.goToLocator(${location.toJson()})") fun goToReference(reference: String) = javascript("Alex.goToReference(${JSONObject.quote(reference)}, false)") fun goToChapter(index: Int) = javascript("Alex.setPage(Alex.chapterPage($index), true)") - fun panBy(dx: Float, dy: Float) = javascript("Alex.panBy($dx,$dy)") - fun customZoomBy(factor: Float) = javascript("Alex.zoomBy($factor)") - fun finishViewportGesture() = javascript("Alex.finishViewportGesture()") fun search(query: String, direction: SearchDirection) = javascript("Alex.search(${JSONObject.quote(query)}, ${JSONObject.quote(direction.wireValue)})") fun clearSearch() = javascript("Alex.clearSearch()") @@ -239,15 +195,7 @@ class ReaderWebView(context: Context) : WebView(context) { } override fun onTouchEvent(event: MotionEvent): Boolean { - scaleGestures.onTouchEvent(event) gestures.onTouchEvent(event) - if (fixedLayout && fixedZoomMode == "custom") { - if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) { - if (viewportDirty && !scaleGestures.isInProgress) finishViewportGesture() - viewportDirty = false - } - return true - } return super.onTouchEvent(event) } @@ -312,15 +260,6 @@ class ReaderWebView(context: Context) : WebView(context) { runCatching { ReaderLocation.fromJson(JSONObject(json)) }.getOrNull()?.let { listener?.onLocationChanged(it) } } - @JavascriptInterface fun onViewportChanged(json: String) = mainHandler.post { - runCatching { JSONObject(json) }.getOrNull()?.let { value -> - val mode = value.optString("zoomMode", "fit-page") - val zoom = value.optInt("customZoom", 100).coerceIn(50, 400) - fixedZoomMode = mode - listener?.onViewportChanged(mode, zoom) - } - } - @JavascriptInterface fun onExternalLink(url: String) = mainHandler.post { listener?.onExternalLink(url) } @JavascriptInterface fun onNavigationJump(json: String) = mainHandler.post { runCatching { ReaderLocation.fromJson(JSONObject(json)) }.getOrNull()?.let { listener?.onNavigationJump(it) } diff --git a/scripts/verify-fixed-layout.ps1 b/scripts/verify-fixed-layout.ps1 new file mode 100644 index 0000000..ea81925 --- /dev/null +++ b/scripts/verify-fixed-layout.ps1 @@ -0,0 +1,97 @@ +[CmdletBinding()] +param( + [string]$Fixture, + [string]$OutputDirectory +) + +$ErrorActionPreference = "Stop" +if (-not $Fixture) { $Fixture = Join-Path $PSScriptRoot "..\tests\fixtures\fixed-layout.epub" } +if (-not $OutputDirectory) { $OutputDirectory = Join-Path $PSScriptRoot "..\verification" } +$sdk = if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } elseif ($env:ANDROID_HOME) { $env:ANDROID_HOME } else { Join-Path $env:LOCALAPPDATA "Android\Sdk" } +$adb = Join-Path $sdk "platform-tools\adb.exe" +if (-not (Test-Path $adb)) { throw "adb was not found under $sdk" } +if (-not (Test-Path $Fixture)) { throw "Fixture not found: $Fixture" } +$device = (& $adb devices | Select-String "^emulator-[0-9]+\s+device$" | Select-Object -First 1) +if (-not $device) { throw "Start the Android virtual device before running this script." } +$serial = $device.ToString().Split("`t")[0] +New-Item -ItemType Directory -Force $OutputDirectory | Out-Null + +function Invoke-Adb([Parameter(ValueFromRemainingArguments=$true)][string[]]$Arguments) { + & $adb -s $serial @Arguments + if ($LASTEXITCODE -ne 0) { throw "adb failed: $($Arguments -join ' ')" } +} +function Wait-Reader([string]$Expected) { + $dump = Join-Path $OutputDirectory "fixed-window.xml" + Remove-Item -ErrorAction SilentlyContinue $dump + Invoke-Adb shell rm '-f' /sdcard/alexandria-fixed-window.xml | Out-Null + for ($attempt = 0; $attempt -lt 60; $attempt++) { + Start-Sleep -Milliseconds 500 + & $adb -s $serial shell uiautomator dump /sdcard/alexandria-fixed-window.xml | Out-Null + if ($LASTEXITCODE -ne 0) { continue } + & $adb -s $serial pull /sdcard/alexandria-fixed-window.xml $dump | Out-Null + if ($LASTEXITCODE -eq 0 -and (Get-Content $dump -Raw) -match $Expected) { return } + } + throw "Reader state did not match: $Expected" +} +function Wait-Library { + $dump = Join-Path $OutputDirectory "fixed-library-window.xml" + Remove-Item -ErrorAction SilentlyContinue $dump + Invoke-Adb shell rm '-f' /sdcard/alexandria-fixed-library.xml | Out-Null + for ($attempt = 0; $attempt -lt 30; $attempt++) { + Start-Sleep -Milliseconds 500 + & $adb -s $serial shell uiautomator dump /sdcard/alexandria-fixed-library.xml | Out-Null + if ($LASTEXITCODE -ne 0) { continue } + & $adb -s $serial pull /sdcard/alexandria-fixed-library.xml $dump | Out-Null + if ($LASTEXITCODE -eq 0 -and (Get-Content $dump -Raw) -match "Fixed Layout Verification") { return } + } + throw "The fixed-layout fixture was not visible in the library." +} +function Capture([string]$Name) { + Invoke-Adb shell screencap '-p' "/sdcard/$Name.png" | Out-Null + Invoke-Adb pull "/sdcard/$Name.png" (Join-Path $OutputDirectory "$Name.png") | Out-Null +} +function Open-Book([string]$Expected) { + Invoke-Adb shell am force-stop com.alexandria.reader + Invoke-Adb shell am start '-W' '-n' com.alexandria.reader/.MainActivity '-a' android.intent.action.MAIN '-c' android.intent.category.LAUNCHER | Out-Null + Wait-Library + Invoke-Adb shell input tap 500 210 + Wait-Reader $Expected +} + +Push-Location (Join-Path $PSScriptRoot "..") +try { + & .\gradlew.bat :app:assembleDebug + if ($LASTEXITCODE -ne 0) { throw "Debug build failed." } + Invoke-Adb install '-r' ".\app\build\outputs\apk\debug\app-debug.apk" | Out-Null + Invoke-Adb shell pm clear com.alexandria.reader | Out-Null + Invoke-Adb shell am start '-W' '-n' com.alexandria.reader/.MainActivity '-a' android.intent.action.MAIN '-c' android.intent.category.LAUNCHER | Out-Null + Start-Sleep -Seconds 1 + Invoke-Adb root | Out-Null + Invoke-Adb wait-for-device + Invoke-Adb push $Fixture /data/user/0/com.alexandria.reader/files/fixed-verification.epub | Out-Null + Invoke-Adb shell chmod 666 /data/user/0/com.alexandria.reader/files/fixed-verification.epub + Invoke-Adb logcat '-c' + Invoke-Adb shell am force-stop com.alexandria.reader + Invoke-Adb shell am start '-W' '-a' android.intent.action.VIEW '-d' file:///data/user/0/com.alexandria.reader/files/fixed-verification.epub '-t' application/epub+zip com.alexandria.reader/.MainActivity | Out-Null + + Wait-Reader "Page 1 of 4" + Capture "fixed-page-1" + Invoke-Adb shell input tap 1282 1748 + Wait-Reader "Page 2 of 4" + Start-Sleep -Seconds 1 + Capture "fixed-page-2" + + Open-Book "Page 2 of 4" + Capture "fixed-restored-page-2" + Invoke-Adb shell input tap 1282 1748 + Wait-Reader "Page 3 of 4" + Capture "fixed-automatic-dithering" + + $fatal = & $adb -s $serial logcat -d -v brief | Select-String "FATAL EXCEPTION|Process: com.alexandria.reader|E/AlexandriaRenderer" + if ($fatal) { throw "Alexandria failed during fixed-layout verification.`n$($fatal -join "`n")" } + Write-Host "Verified fixed-layout rendering, automatic image dithering, and position restoration. Screenshots are in $OutputDirectory" +} +finally { + Remove-Item -ErrorAction SilentlyContinue (Join-Path $OutputDirectory "fixed-library-window.xml") + Pop-Location +} diff --git a/scripts/verify-fixed-page-tools.ps1 b/scripts/verify-fixed-page-tools.ps1 deleted file mode 100644 index 45900ab..0000000 --- a/scripts/verify-fixed-page-tools.ps1 +++ /dev/null @@ -1,151 +0,0 @@ -[CmdletBinding()] -param( - [string]$Fixture, - [string]$OutputDirectory -) - -$ErrorActionPreference = "Stop" -if (-not $Fixture) { $Fixture = Join-Path $PSScriptRoot "..\tests\fixtures\fixed-layout.epub" } -if (-not $OutputDirectory) { $OutputDirectory = Join-Path $PSScriptRoot "..\verification" } -$sdk = if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } elseif ($env:ANDROID_HOME) { $env:ANDROID_HOME } else { Join-Path $env:LOCALAPPDATA "Android\Sdk" } -$adb = Join-Path $sdk "platform-tools\adb.exe" -if (-not (Test-Path $adb)) { throw "adb was not found under $sdk" } -if (-not (Test-Path $Fixture)) { throw "Fixture not found: $Fixture" } -$device = (& $adb devices | Select-String "^emulator-[0-9]+\s+device$" | Select-Object -First 1) -if (-not $device) { throw "Start the Android virtual device before running this script." } -$serial = $device.ToString().Split("`t")[0] -$hashStream = [System.IO.File]::OpenRead((Resolve-Path $Fixture)) -try { - $hashBytes = [System.Security.Cryptography.SHA256]::Create().ComputeHash($hashStream) - $bookId = (($hashBytes | ForEach-Object { $_.ToString("x2") }) -join "").Substring(0, 24) -} -finally { $hashStream.Dispose() } -$bookDirectory = "/data/user/0/com.alexandria.reader/files/library/$bookId" -New-Item -ItemType Directory -Force $OutputDirectory | Out-Null - -function Invoke-Adb([Parameter(ValueFromRemainingArguments=$true)][string[]]$Arguments) { - & $adb -s $serial @Arguments - if ($LASTEXITCODE -ne 0) { throw "adb failed: $($Arguments -join ' ')" } -} -function Wait-Reader([string]$Expected) { - $dump = Join-Path $OutputDirectory "fixed-window.xml" - Remove-Item -ErrorAction SilentlyContinue $dump - & $adb -s $serial shell rm -f /sdcard/alexandria-fixed-window.xml | Out-Null - for ($attempt = 0; $attempt -lt 60; $attempt++) { - Start-Sleep -Milliseconds 500 - & $adb -s $serial shell uiautomator dump /sdcard/alexandria-fixed-window.xml | Out-Null - if ($LASTEXITCODE -ne 0) { continue } - & $adb -s $serial pull /sdcard/alexandria-fixed-window.xml $dump | Out-Null - if ($LASTEXITCODE -eq 0 -and (Get-Content $dump -Raw) -match $Expected) { return } - } - throw "Reader state did not match: $Expected" -} -function Wait-Library { - $dump = Join-Path $OutputDirectory "fixed-library-window.xml" - Remove-Item -ErrorAction SilentlyContinue $dump - & $adb -s $serial shell rm -f /sdcard/alexandria-fixed-library.xml | Out-Null - for ($attempt = 0; $attempt -lt 30; $attempt++) { - Start-Sleep -Milliseconds 500 - & $adb -s $serial shell uiautomator dump /sdcard/alexandria-fixed-library.xml | Out-Null - if ($LASTEXITCODE -ne 0) { continue } - & $adb -s $serial pull /sdcard/alexandria-fixed-library.xml $dump | Out-Null - if ($LASTEXITCODE -eq 0 -and (Get-Content $dump -Raw) -match "Fixed Page Tools Verification") { return } - } - throw "The fixed-page fixture was not visible in the library." -} -function Capture([string]$Name) { - Invoke-Adb shell screencap '-p' "/sdcard/$Name.png" - Invoke-Adb pull "/sdcard/$Name.png" (Join-Path $OutputDirectory "$Name.png") | Out-Null -} -function New-Settings( - [string]$ZoomMode = "fit-page", [int]$CustomZoom = 100, [string]$ScrollMode = "screen", - [string]$CropScheme = "none", [hashtable]$CropEven = @{}, [hashtable]$CropOdd = @{}, - [double]$Contrast = 1.0, [int]$GrayPoint = 255, [string]$Dithering = "off" -) { - [ordered]@{ - fontFamily="Publisher"; fontSize=20; lineHeight=1.32; margin=32; textAlign="publisher" - publisherStyles=$true; hyphenation=$true; mode="paged"; brightness=-1 - zoomMode=$ZoomMode; customZoom=$CustomZoom; scrollMode=$ScrollMode; cropScheme=$CropScheme - cropAny=@{}; cropEven=$CropEven; cropOdd=$CropOdd - contrastExponent=$Contrast; grayPoint=$GrayPoint; dithering=$Dithering - } | ConvertTo-Json -Depth 5 -Compress -} -function Set-State([string]$Settings, [int]$Page, [double]$ViewportX = 0, [double]$ViewportY = 0) { - $settingsFile = Join-Path $OutputDirectory "fixed-settings.json" - $locationFile = Join-Path $OutputDirectory "fixed-location.json" - Invoke-Adb shell am force-stop com.alexandria.reader - $utf8 = New-Object System.Text.UTF8Encoding($false) - [System.IO.File]::WriteAllText($settingsFile, $Settings, $utf8) - $progress = if ($Page -eq 0) { 0 } else { $Page / 3.0 } - $location = [ordered]@{page=$Page;totalPages=4;chapter=$Page;offset=0;progress=$progress;viewportX=$ViewportX;viewportY=$ViewportY} | ConvertTo-Json -Compress - [System.IO.File]::WriteAllText($locationFile, $location, $utf8) - Invoke-Adb push $settingsFile "$bookDirectory/settings.json" | Out-Null - Invoke-Adb push $locationFile "$bookDirectory/location.json" | Out-Null - Invoke-Adb shell chmod 666 "$bookDirectory/settings.json" "$bookDirectory/location.json" -} -function Open-Book([string]$Expected) { - Invoke-Adb shell am force-stop com.alexandria.reader - Invoke-Adb shell am start '-W' '-n' com.alexandria.reader/.MainActivity '-a' android.intent.action.MAIN '-c' android.intent.category.LAUNCHER | Out-Null - Wait-Library - Invoke-Adb shell input tap 500 210 - Wait-Reader $Expected -} - -Push-Location (Join-Path $PSScriptRoot "..") -try { - & .\gradlew.bat :app:assembleDebug - if ($LASTEXITCODE -ne 0) { throw "Debug build failed." } - Invoke-Adb install '-r' ".\app\build\outputs\apk\debug\app-debug.apk" | Out-Null - Invoke-Adb shell pm clear com.alexandria.reader | Out-Null - Invoke-Adb shell am start '-W' '-n' com.alexandria.reader/.MainActivity '-a' android.intent.action.MAIN '-c' android.intent.category.LAUNCHER | Out-Null - Start-Sleep -Seconds 1 - Invoke-Adb root | Out-Null - Invoke-Adb wait-for-device - Invoke-Adb push $Fixture /data/user/0/com.alexandria.reader/files/fixed-verification.epub | Out-Null - Invoke-Adb shell chmod 666 /data/user/0/com.alexandria.reader/files/fixed-verification.epub - Invoke-Adb logcat '-c' - Invoke-Adb shell am force-stop com.alexandria.reader - Invoke-Adb shell am start '-W' '-a' android.intent.action.VIEW '-d' file:///data/user/0/com.alexandria.reader/files/fixed-verification.epub '-t' application/epub+zip com.alexandria.reader/.MainActivity | Out-Null - Wait-Reader "Page 1 of 4" - Capture "fixed-fit-page" - - Set-State (New-Settings -CropScheme "even-odd" -CropOdd @{left=9;top=8;right=9;bottom=8} -CropEven @{left=14;top=8;right=14;bottom=8}) 0 - Open-Book "Page 1 of 4" - Capture "fixed-odd-cropped" - Invoke-Adb shell input tap 1357 1748 - Wait-Reader "Page 2 of 4" - Capture "fixed-even-cropped" - - Set-State (New-Settings -ZoomMode "custom" -CustomZoom 170 -CropScheme "even-odd" -CropOdd @{left=9;top=8;right=9;bottom=8} -CropEven @{left=14;top=8;right=14;bottom=8}) 1 250 250 - Open-Book "Page 2 of 4" - Capture "fixed-custom-before-pan" - Invoke-Adb shell input swipe 1050 1400 430 520 1000 - Start-Sleep -Seconds 2 - $panned = (Invoke-Adb shell cat "$bookDirectory/location.json" | Out-String | ConvertFrom-Json) - if ($panned.viewportY -le 250) { throw "Custom pan did not update the persisted viewport." } - Capture "fixed-custom-after-pan" - - Set-State (New-Settings -Contrast 2.5 -GrayPoint 150 -Dithering "g16") 2 - Open-Book "Page 3 of 4" - Capture "fixed-graypoint-dither-g16" - Set-State (New-Settings -Contrast 2.5 -GrayPoint 150 -Dithering "g2") 2 - Open-Book "Page 3 of 4" - Capture "fixed-graypoint-dither-g2" - - Set-State (New-Settings -ZoomMode "fit-width" -ScrollMode "screen") 0 - Open-Book "Page 1 of 4" - Capture "fixed-line-cut-before" - Invoke-Adb shell input tap 1282 1748 - Start-Sleep -Seconds 2 - $cut = (Invoke-Adb shell cat "$bookDirectory/location.json" | Out-String | ConvertFrom-Json) - if ($cut.viewportY -lt 1300 -or $cut.viewportY -gt 1400) { throw "The line-preserving cut was not selected." } - Capture "fixed-line-cut-after" - - $fatal = & $adb -s $serial logcat -d -v brief | Select-String "FATAL EXCEPTION|Process: com.alexandria.reader|E/AlexandriaRenderer" - if ($fatal) { throw "Alexandria failed during fixed-page verification.`n$($fatal -join "`n")" } - Write-Host "Verified fixed-page tools. Screenshots are in $OutputDirectory" -} -finally { - Remove-Item -ErrorAction SilentlyContinue (Join-Path $OutputDirectory "fixed-settings.json"), (Join-Path $OutputDirectory "fixed-location.json"), (Join-Path $OutputDirectory "fixed-library-window.xml") - Pop-Location -} diff --git a/tests/fixtures/fixed-layout-src/OEBPS/fixed.css b/tests/fixtures/fixed-layout-src/OEBPS/fixed.css index 6999a1d..f4ef85d 100644 --- a/tests/fixtures/fixed-layout-src/OEBPS/fixed.css +++ b/tests/fixtures/fixed-layout-src/OEBPS/fixed.css @@ -5,7 +5,7 @@ p { margin:0 0 28px; font-size:38px; line-height:1.42; } .rule { height:12px; margin:38px 0; background:#111; } .swatches { display:flex; height:150px; margin-top:50px; border:2px solid #111; } .swatches span { flex:1; } +.graphic { display:block; width:760px; height:300px; margin:70px auto 0; border:4px solid #111; } .footer { position:absolute; left:0; right:0; bottom:42px; text-align:center; font:32px sans-serif; } -.cut-test { position:absolute; left:260px; top:1325px; margin:0; font:700 38px/1.42 sans-serif; white-space:nowrap; } -.crop-label { position:absolute; color:#333; font:28px sans-serif; } -.north { left:480px; top:35px; }.south { left:470px; bottom:40px; }.west { left:16px; top:760px; transform:rotate(-90deg); }.east { right:15px; top:760px; transform:rotate(90deg); } +.edge-label { position:absolute; color:#333; font:28px sans-serif; } +.north { left:500px; top:35px; }.south { left:480px; bottom:40px; }.west { left:16px; top:760px; transform:rotate(-90deg); }.east { right:15px; top:760px; transform:rotate(90deg); } diff --git a/tests/fixtures/fixed-layout-src/OEBPS/gradient.png b/tests/fixtures/fixed-layout-src/OEBPS/gradient.png new file mode 100644 index 0000000000000000000000000000000000000000..e955d3bb611e26dc4aab283ebdbe5b5157735b87 GIT binary patch literal 2060 zcmeAS@N?(olHy`uVBq!ia0y~yVEVzpz^KE)z`(#TnQz8y1_t)ao-U3d6}R4AVs4UP zV_?{@`t7=Hxy4&^Z-dF`)w#E~_1dPKlok>Y5YPx%aG;Tyol{69o| zJWVHZQ`*^Cy3yO-+}!;9#6;!vb8~hUJv}wox_q8Z<)>$7XY0rB`}6bj|G(vpOsrgD zIuR=Z79MKl7S{|~l5ug-(JoQ#xIGmhCrs0cTotnN(o*l~n!(F*Zf<&dYN~epzM57p zQLV5w5i5h1o|>v1zA|WO*40&4SB0*Q+gsHuWtw$uP2}pZwYRorUtbx#Jn!zVudlAI zj^AI`%*HEamUCl8;NoMw(&m|$mSkRDc6OF&_Psro-D0{?+j6e13VnTT?d>g@m$&8K zE_-`xYySOx&h31%)@5(5tPFmBZtm`qmzTC?U(dU{tMvW7z3%;Tw$TwNXh{@&i- zUtV6`o`1jY@2{`#@7Et_U}R?JlQGG-@Zex`JCCGM$%_k#hue7N?P`v62r9ex$(Uwc z0YyZQq;c7s8-L~JN{c*wO|OgC;%#mPyk-qZDBx8>aY^z?N2 znuv`>Pfvxe4qF?yw+iIj>+52-=iUAF^|g7gc z_V#x9n;RR8pP!4}UH10g-slq&=jT`!e|vKi}UR->9 zynnt;MXa`uh5M{=GewzrVc&IdXsfe~`1}?dyJhdHMPI z`S^V`KYx9Fy*>Z_zWV?FK&IH&|NHgz_4oJp>;JKI=bP0l+XkK9|Yfi literal 0 HcmV?d00001 diff --git a/tests/fixtures/fixed-layout-src/OEBPS/nav.xhtml b/tests/fixtures/fixed-layout-src/OEBPS/nav.xhtml index 53ed9bf..c585840 100644 --- a/tests/fixtures/fixed-layout-src/OEBPS/nav.xhtml +++ b/tests/fixtures/fixed-layout-src/OEBPS/nav.xhtml @@ -1,2 +1,2 @@ -Contents +Contents diff --git a/tests/fixtures/fixed-layout-src/OEBPS/package.opf b/tests/fixtures/fixed-layout-src/OEBPS/package.opf index b728fea..e9df616 100644 --- a/tests/fixtures/fixed-layout-src/OEBPS/package.opf +++ b/tests/fixtures/fixed-layout-src/OEBPS/package.opf @@ -1,8 +1,8 @@ - urn:alexandria:fixed-tools-fixture - Fixed Page Tools Verification + urn:alexandria:fixed-layout-fixture + Fixed Layout Verification Alexandria Tests en pre-paginated @@ -12,6 +12,7 @@ + diff --git a/tests/fixtures/fixed-layout-src/OEBPS/page1.xhtml b/tests/fixtures/fixed-layout-src/OEBPS/page1.xhtml index ee7435b..2bfb863 100644 --- a/tests/fixtures/fixed-layout-src/OEBPS/page1.xhtml +++ b/tests/fixtures/fixed-layout-src/OEBPS/page1.xhtml @@ -1,2 +1,2 @@ -Odd page oneTOP CROP AREABOTTOM CROP AREALEFT CROPRIGHT CROP

FIXED PAGE 1 · ODD

This fixed-layout page has a 1200 by 1600 pixel viewport.

Fit-to-width cuts must stop between these lines instead of cutting through letter shapes.

The next line is deliberately close to a screen boundary for cut verification.

LINE MUST STAY WHOLE AT THE CUT

+Fixed page onePAGE TOPPAGE BOTTOMPAGE LEFTPAGE RIGHT

FIXED PAGE 1

This fixed-layout page has a 1200 by 1600 pixel viewport.

Alexandria fits the complete publisher page inside the available reader area.

All four labeled page edges must remain visible.

diff --git a/tests/fixtures/fixed-layout-src/OEBPS/page2.xhtml b/tests/fixtures/fixed-layout-src/OEBPS/page2.xhtml index 7d32a82..109897f 100644 --- a/tests/fixtures/fixed-layout-src/OEBPS/page2.xhtml +++ b/tests/fixtures/fixed-layout-src/OEBPS/page2.xhtml @@ -1,2 +1,2 @@ -Even page twoEVEN TOP MARGINEVEN BOTTOM MARGINEVEN LEFTEVEN RIGHT

FIXED PAGE 2 · EVEN

This page has visibly different side margins.

The even-and-odd crop scheme must retain a separate rectangle for this page.

Custom zoom and pan must remain bounded by the cropped page.

+Fixed page twoPAGE TOPPAGE BOTTOMPAGE LEFTPAGE RIGHT

FIXED PAGE 2

Page navigation advances by one complete fixed page.

The saved reading position must reopen this second page after a restart.

The page remains centered through a single automatic fitting rule.

diff --git a/tests/fixtures/fixed-layout-src/OEBPS/page3.xhtml b/tests/fixtures/fixed-layout-src/OEBPS/page3.xhtml index bbcb5ff..c51b047 100644 --- a/tests/fixtures/fixed-layout-src/OEBPS/page3.xhtml +++ b/tests/fixtures/fixed-layout-src/OEBPS/page3.xhtml @@ -1,2 +1,2 @@ -Odd page threeTOP CROP AREABOTTOM CROP AREALEFT CROPRIGHT CROP

FIXED PAGE 3 · ODD

Gray-point adjustment changes the pivot used by the contrast curve.

Sixteen-level dithering preserves intermediate shades on grayscale displays.

Black-and-white dithering produces a two-level halftone.

+Dithered imagePAGE TOPPAGE BOTTOMPAGE LEFTPAGE RIGHT

DITHERED IMAGE

Images receive automatic 16-level grayscale spatial dithering.

Dithering has no reader control and remains consistent for every book.

Grayscale gradient
diff --git a/tests/fixtures/fixed-layout-src/OEBPS/page4.xhtml b/tests/fixtures/fixed-layout-src/OEBPS/page4.xhtml index 2013019..e4aecda 100644 --- a/tests/fixtures/fixed-layout-src/OEBPS/page4.xhtml +++ b/tests/fixtures/fixed-layout-src/OEBPS/page4.xhtml @@ -1,2 +1,2 @@ -Even page fourEVEN TOP MARGINEVEN BOTTOM MARGINEVEN LEFTEVEN RIGHT

FIXED PAGE 4 · EVEN

The final page verifies page-boundary navigation after vertical slices.

Previous returns to the bottom slice of the prior fixed page.

All display settings and viewport coordinates persist after restart.

+Fixed page fourPAGE TOPPAGE BOTTOMPAGE LEFTPAGE RIGHT

FIXED PAGE 4

The final page verifies the end of the fixed-layout reading order.

Previous and next controls always move across complete publisher pages.

Portrait and landscape layouts use the same automatic fitting rule.

diff --git a/tests/fixtures/fixed-layout.epub b/tests/fixtures/fixed-layout.epub index 53f505974c8449d49996c41a228dca3e555d756b..e0d5ab3c1535fbf182e5b076b40c372d55a0a31f 100644 GIT binary patch literal 5383 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 literal 4489 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 L7#LhYqX`TE6K;=$ -- 2.55.0