- 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
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:
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)
val directory: String,
val viewportWidth: Int,
val viewportHeight: Int,
- val lineBoxes: String,
)
fun write(publication: EpubPublication, output: File): File {
}
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<String, String>()
.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('"')
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<Element, Metrics>()
- val css = buildString {
- val loaded = mutableSetOf<String>()
- 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<Pair<Int, Int>>()
- 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<Int, Int> {
val viewport = document.getAllElements().firstOrNull {
it.tagName().substringAfter(':').equals("meta", true) && it.attr("name").equals("viewport", true)
.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; }
</style>
</head>
<body class="${if (publication.fixedLayout) "alex-fixed" else "alex-paged"} alex-hyphenate">
-<svg width="0" height="0" aria-hidden="true" style="position:absolute"><defs><filter id="alex-page-filter" x="-10%" y="-10%" width="120%" height="120%" color-interpolation-filters="sRGB"></filter></defs></svg>
+<svg width="0" height="0" aria-hidden="true" style="position:absolute"><defs><filter id="alex-image-dither" x="-10%" y="-10%" width="120%" height="120%" color-interpolation-filters="sRGB"></filter></defs></svg>
$content
-<div id="alex-cut-mask" aria-hidden="true"></div>
<script>
window.ALEX_REFERENCES = $references;
+$IMAGE_DITHER_SCRIPT
${if (publication.fixedLayout) FIXED_READER_SCRIPT else REFLOWABLE_READER_SCRIPT}
</script>
</body>
</html>"""
+ 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=c<g?g*Math.pow(c/g,exponent):(c>g&&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; };
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) {
(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(c<g) out=g*Math.pow(c/g,exponent);
- else if(c>g && 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(currentTarget<currentEnd-1){
- var currentCut=lineCutOnPage(page,currentTarget,1,scale,state.offsetY);
- if(currentCut<currentTarget-1)showCutMask(currentTarget-currentCut,scale);
- }
- }
- if(mode==='fit-width' && (state.settings.scrollMode||'screen')==='screen' && state.page<pages.length-1){
- var remaining=Math.max(0,(crop.y+crop.height-state.offsetY)*scale);
- if(remaining<availableHeight-1){
- var next=pages[state.page+1],nextCrop=sourceCrop(next,state.page+1),nextSize=nextCrop.size,nextScale=availableWidth/nextCrop.width;
- next.classList.add('alex-stream-next');next.style.width=nextSize.width+'px';next.style.height=nextSize.height+'px';
- next.style.transform='translate('+(-nextCrop.x*nextScale)+'px,'+(remaining-nextCrop.y*nextScale)+'px) scale('+nextScale+')';
- next.style.filter=page.style.filter;state.nextVisible=Math.min(nextCrop.height,(availableHeight-remaining)/nextScale);
- var nextTarget=nextCrop.y+state.nextVisible,nextCut=lineCutOnPage(next,nextTarget,1,nextScale,nextCrop.y);
- if(nextCut<nextTarget-1){state.nextVisible=Math.max(0,nextCut-nextCrop.y);showCutMask(nextTarget-nextCut,nextScale);}
- }
- }
- }
- function locator() {
- return {page:state.page,totalPages:state.total,chapter:state.page,offset:0,
- progress:state.total<=1?0:state.page/(state.total-1),
- viewportX:isFinite(state.offsetX)?state.offsetX:0,viewportY:isFinite(state.offsetY)?state.offsetY:0};
- }
- function notifyPage() {
- if(!window.Android||!Android.onPageChanged)return;
- Android.onPageChanged(JSON.stringify(locator()));
- }
- function recalculate() {
- state.total=pages.length;layoutPage();notifyPage();
- }
- function setPage(page,report) {
- var next=clamp(Math.round(number(page,0)),0,state.total-1), changed=next!==state.page;
- state.page=next;if(changed){state.offsetX=NaN;state.offsetY=NaN;}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 measuredLines(page) {
- var index=number(page.getAttribute('data-spine'),0);
- if(state.lineCache[index])return state.lineCache[index];
- var lines=String(page.getAttribute('data-line-boxes')||'').split(',').map(function(value){
- var pair=value.split(':');return{top:number(pair[0],0),bottom:number(pair[1],0)};
- }).filter(function(line){return line.bottom>line.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;i<lines.length;i){
- var line=lines[i];
- if(target>line.top-2&&target<line.bottom+2){
- if(direction>0) 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+visibleHeight<end-1){
- var target=linePreservingCut(Math.min(end-1,state.offsetY+visibleHeight),1);
- state.offsetY=clamp(target,state.offsetY+Math.min(8,visibleHeight/10),end-1);layoutPage();notifyPage();return;
- }
- if(state.page<state.total-1){
- var alreadyShown=(state.settings.scrollMode||'screen')==='screen'?state.nextVisible:0;
- state.page+=1;state.offsetX=NaN;state.offsetY=NaN;layoutPage();
- if(alreadyShown>0){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));
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;
}
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);});
})();
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)
put("chapter", chapter)
put("offset", offset)
put("progress", progress.toDouble())
- put("viewportX", viewportX.toDouble())
- put("viewportY", viewportY.toDouble())
}
companion object {
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,
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)
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 {
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",
)
}
}
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
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
isFocusableInTouchMode = true
buildReader()
webView.listener = this
- webView.configurePageTools(publication.fixedLayout, settings)
webView.load(repository.readerFile(publication))
repository.markOpened(publication.book.id)
applyBrightness()
}
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 {
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()
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()
}
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 }
.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<String>, 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) {
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()
}
}
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)
- }
- }
-
}
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
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)
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
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
}
})
}
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()")
}
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)
}
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) }
--- /dev/null
+[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
+}
+++ /dev/null
-[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
-}
.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); }
<?xml version="1.0" encoding="UTF-8"?>
-<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"><head><title>Contents</title></head><body><nav epub:type="toc"><ol><li><a href="page1.xhtml">Odd page one</a></li><li><a href="page2.xhtml">Even page two</a></li><li><a href="page3.xhtml">Odd page three</a></li><li><a href="page4.xhtml">Even page four</a></li></ol></nav></body></html>
+<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"><head><title>Contents</title></head><body><nav epub:type="toc"><ol><li><a href="page1.xhtml">Fixed page one</a></li><li><a href="page2.xhtml">Fixed page two</a></li><li><a href="page3.xhtml">Dithered image</a></li><li><a href="page4.xhtml">Fixed page four</a></li></ol></nav></body></html>
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid" prefix="rendition: http://www.idpf.org/vocab/rendition/#">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
- <dc:identifier id="uid">urn:alexandria:fixed-tools-fixture</dc:identifier>
- <dc:title>Fixed Page Tools Verification</dc:title>
+ <dc:identifier id="uid">urn:alexandria:fixed-layout-fixture</dc:identifier>
+ <dc:title>Fixed Layout Verification</dc:title>
<dc:creator>Alexandria Tests</dc:creator>
<dc:language>en</dc:language>
<meta property="rendition:layout">pre-paginated</meta>
<manifest>
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
<item id="css" href="fixed.css" media-type="text/css"/>
+ <item id="gradient" href="gradient.png" media-type="image/png"/>
<item id="p1" href="page1.xhtml" media-type="application/xhtml+xml"/>
<item id="p2" href="page2.xhtml" media-type="application/xhtml+xml"/>
<item id="p3" href="page3.xhtml" media-type="application/xhtml+xml"/>
<?xml version="1.0" encoding="UTF-8"?>
-<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Odd page one</title><meta name="viewport" content="width=1200,height=1600"/><link rel="stylesheet" href="fixed.css"/></head><body><span class="crop-label north">TOP CROP AREA</span><span class="crop-label south">BOTTOM CROP AREA</span><span class="crop-label west">LEFT CROP</span><span class="crop-label east">RIGHT CROP</span><main class="trim"><h1>FIXED PAGE 1 · ODD</h1><p>This fixed-layout page has a 1200 by 1600 pixel viewport.</p><p>Fit-to-width cuts must stop between these lines instead of cutting through letter shapes.</p><p>The next line is deliberately close to a screen boundary for cut verification.</p><div class="rule"></div><div class="swatches"><span style="background:#000"></span><span style="background:#333"></span><span style="background:#666"></span><span style="background:#999"></span><span style="background:#ccc"></span><span style="background:#fff"></span></div><div class="footer">1</div></main><p class="cut-test">LINE MUST STAY WHOLE AT THE CUT</p></body></html>
+<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Fixed page one</title><meta name="viewport" content="width=1200,height=1600"/><link rel="stylesheet" href="fixed.css"/></head><body><span class="edge-label north">PAGE TOP</span><span class="edge-label south">PAGE BOTTOM</span><span class="edge-label west">PAGE LEFT</span><span class="edge-label east">PAGE RIGHT</span><main class="trim"><h1>FIXED PAGE 1</h1><p>This fixed-layout page has a 1200 by 1600 pixel viewport.</p><p>Alexandria fits the complete publisher page inside the available reader area.</p><p>All four labeled page edges must remain visible.</p><div class="rule"></div><div class="swatches"><span style="background:#000"></span><span style="background:#333"></span><span style="background:#666"></span><span style="background:#999"></span><span style="background:#ccc"></span><span style="background:#fff"></span></div><div class="footer">1</div></main></body></html>
<?xml version="1.0" encoding="UTF-8"?>
-<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Even page two</title><meta name="viewport" content="width=1200,height=1600"/><link rel="stylesheet" href="fixed.css"/></head><body><span class="crop-label north">EVEN TOP MARGIN</span><span class="crop-label south">EVEN BOTTOM MARGIN</span><span class="crop-label west">EVEN LEFT</span><span class="crop-label east">EVEN RIGHT</span><main class="trim" style="left:150px;width:900px"><h1>FIXED PAGE 2 · EVEN</h1><p>This page has visibly different side margins.</p><p>The even-and-odd crop scheme must retain a separate rectangle for this page.</p><p>Custom zoom and pan must remain bounded by the cropped page.</p><div class="rule"></div><div class="swatches"><span style="background:#101010"></span><span style="background:#484848"></span><span style="background:#808080"></span><span style="background:#b8b8b8"></span><span style="background:#f0f0f0"></span></div><div class="footer">2</div></main></body></html>
+<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Fixed page two</title><meta name="viewport" content="width=1200,height=1600"/><link rel="stylesheet" href="fixed.css"/></head><body><span class="edge-label north">PAGE TOP</span><span class="edge-label south">PAGE BOTTOM</span><span class="edge-label west">PAGE LEFT</span><span class="edge-label east">PAGE RIGHT</span><main class="trim"><h1>FIXED PAGE 2</h1><p>Page navigation advances by one complete fixed page.</p><p>The saved reading position must reopen this second page after a restart.</p><p>The page remains centered through a single automatic fitting rule.</p><div class="rule"></div><div class="swatches"><span style="background:#101010"></span><span style="background:#484848"></span><span style="background:#808080"></span><span style="background:#b8b8b8"></span><span style="background:#f0f0f0"></span></div><div class="footer">2</div></main></body></html>
<?xml version="1.0" encoding="UTF-8"?>
-<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Odd page three</title><meta name="viewport" content="width=1200,height=1600"/><link rel="stylesheet" href="fixed.css"/></head><body><span class="crop-label north">TOP CROP AREA</span><span class="crop-label south">BOTTOM CROP AREA</span><span class="crop-label west">LEFT CROP</span><span class="crop-label east">RIGHT CROP</span><main class="trim"><h1>FIXED PAGE 3 · ODD</h1><p>Gray-point adjustment changes the pivot used by the contrast curve.</p><p>Sixteen-level dithering preserves intermediate shades on grayscale displays.</p><p>Black-and-white dithering produces a two-level halftone.</p><div class="rule"></div><div class="swatches"><span style="background:#181818"></span><span style="background:#505050"></span><span style="background:#888"></span><span style="background:#c0c0c0"></span><span style="background:#f8f8f8"></span></div><div class="footer">3</div></main></body></html>
+<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Dithered image</title><meta name="viewport" content="width=1200,height=1600"/><link rel="stylesheet" href="fixed.css"/></head><body><span class="edge-label north">PAGE TOP</span><span class="edge-label south">PAGE BOTTOM</span><span class="edge-label west">PAGE LEFT</span><span class="edge-label east">PAGE RIGHT</span><main class="trim"><h1>DITHERED IMAGE</h1><p>Images receive automatic 16-level grayscale spatial dithering.</p><p>Dithering has no reader control and remains consistent for every book.</p><img class="graphic" src="gradient.png" alt="Grayscale gradient"/><div class="footer">3</div></main></body></html>
<?xml version="1.0" encoding="UTF-8"?>
-<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Even page four</title><meta name="viewport" content="width=1200,height=1600"/><link rel="stylesheet" href="fixed.css"/></head><body><span class="crop-label north">EVEN TOP MARGIN</span><span class="crop-label south">EVEN BOTTOM MARGIN</span><span class="crop-label west">EVEN LEFT</span><span class="crop-label east">EVEN RIGHT</span><main class="trim" style="left:150px;width:900px"><h1>FIXED PAGE 4 · EVEN</h1><p>The final page verifies page-boundary navigation after vertical slices.</p><p>Previous returns to the bottom slice of the prior fixed page.</p><p>All display settings and viewport coordinates persist after restart.</p><div class="rule"></div><div class="swatches"><span style="background:#000"></span><span style="background:#404040"></span><span style="background:#808080"></span><span style="background:#c0c0c0"></span><span style="background:#fff"></span></div><div class="footer">4</div></main></body></html>
+<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Fixed page four</title><meta name="viewport" content="width=1200,height=1600"/><link rel="stylesheet" href="fixed.css"/></head><body><span class="edge-label north">PAGE TOP</span><span class="edge-label south">PAGE BOTTOM</span><span class="edge-label west">PAGE LEFT</span><span class="edge-label east">PAGE RIGHT</span><main class="trim"><h1>FIXED PAGE 4</h1><p>The final page verifies the end of the fixed-layout reading order.</p><p>Previous and next controls always move across complete publisher pages.</p><p>Portrait and landscape layouts use the same automatic fitting rule.</p><div class="rule"></div><div class="swatches"><span style="background:#000"></span><span style="background:#404040"></span><span style="background:#808080"></span><span style="background:#c0c0c0"></span><span style="background:#fff"></span></div><div class="footer">4</div></main></body></html>