Skip to content
This repository was archived by the owner on Aug 21, 2025. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions src/app/preprocess/preprocess.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
Size
<mat-slider
[min]="8"
[max]="maxTargetWidth"
[max]="maxTargetScale"
[step]="1"
thumbLabel
vertical
[(ngModel)]="targetWidth"
(input)="targetWidth = $event.value!; ngOnChanges()"
[(ngModel)]="targetScale"
(input)="resizeTo($event.value!)"
></mat-slider>

Colors
Expand All @@ -27,8 +27,8 @@

<div class="details text-center">
<div>
{{ targetWidth }} x
{{ targetHeight }}
{{ croppedWidth }} x
{{ croppedHeight }}
</div>
<div>
{{ realWidth | number: "1.0-0" }} x {{ realHeight | number: "1.0-0" }} cm
Expand All @@ -37,5 +37,28 @@
</mat-toolbar>

<div class="scroll-container" #scrollContainer>
<canvas #canvas></canvas>
<div class="canvas-buttons">
<div
*ngFor="let entry of Edge | keyvalue"
class="expand-buttons expand-{{ entry.value }}"
>
<button
mat-button
(click)="shrink(entry.value)"
[disabled]="!canShrink(entry.value)"
[attr.aria-label]="'Shrink from ' + entry.value + ' edge'"
>
<mat-icon>expand_less</mat-icon>
</button>
<button
mat-button
(click)="expand(entry.value)"
[disabled]="!canExpand(entry.value)"
[attr.aria-label]="'Expand from ' + entry.value + ' edge'"
>
<mat-icon>expand_more</mat-icon>
</button>
</div>
<canvas #canvas></canvas>
</div>
</div>
61 changes: 60 additions & 1 deletion src/app/preprocess/preprocess.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,71 @@ mat-toolbar.vertical {
}
}

$button-size: 36px;

.scroll-container {
flex: 1;
display: flex;
align-items: center;
overflow: auto;

& > * {
.canvas-buttons {
margin: 0 auto;
position: relative;

canvas {
border: 1px solid var(--accent-border);
}

.expand-buttons {
display: flex;
position: absolute;

top: 0;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;

&.expand-bottom {
top: initial;
bottom: -$button-size + 6px;
}

&.expand-top {
bottom: initial;
top: -$button-size;
transform: scale(-1, -1);
}

&.expand-left,
&.expand-right {
flex-direction: column;

.mat-button {
min-height: 64px;
line-height: 24px;
min-width: 0;
width: 36px;
padding: 16px 0;

mat-icon {
transform: rotate(90deg);
}
}
}

&.expand-left {
right: initial;
left: -$button-size;
}

&.expand-right {
left: initial;
right: -$button-size;
transform: scale(-1, -1);
}
}
}
}
134 changes: 105 additions & 29 deletions src/app/preprocess/preprocess.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ import {
import { downscale, EditableImageData, getContext2D } from '../image';
import { requireNonNull } from '../state';

enum Edge {
TOP = 'top',
RIGHT = 'right',
BOTTOM = 'bottom',
LEFT = 'left',
}

@Component({
selector: 'app-preprocess',
templateUrl: './preprocess.component.html',
Expand All @@ -37,60 +44,81 @@ import { requireNonNull } from '../state';
export class PreprocessComponent
implements OnInit, AfterViewInit, OnChanges, OnDestroy
{
readonly Edge = Edge;

@ViewChild('canvas') canvas!: ElementRef<HTMLCanvasElement>;

private ctx!: CanvasRenderingContext2D;

readonly minTargetWidth = 8;
readonly minTargetScale = 8;

targetColors = 20;

targetWidth = this.minTargetWidth;
targetScale = this.minTargetScale;

@Input() imageData!: EditableImageData;

/** Emits the final image OnDestroy to prevent two-way data binding loops. */
@Output()
readonly imageDataOnDestroy = new EventEmitter<EditableImageData>();

get maxTargetWidth() {
get maxTargetScale() {
return Math.min(80, this.imageData?.width ?? 80);
}

get maxTargetColors() {
return Math.min(20, this.imageData?.count().size ?? 20);
}

get targetHeight() {
get scaledHeight() {
return Math.round(
(this.imageData.height / this.imageData.width) * this.targetWidth
(this.imageData.height / this.imageData.width) * this.scaledWidth
);
}

get scaledWidth() {
return this.targetScale;
}

get croppedWidth() {
return this.scaledWidth - this.crops.left - this.crops.right;
}

get croppedHeight() {
return this.scaledHeight - this.crops.top - this.crops.bottom;
}

get realWidth() {
return this.targetWidth * 7.6;
return this.croppedWidth * 7.6;
}

get realHeight() {
return this.targetHeight * 7.6;
return this.croppedHeight * 7.6;
}

crops = {
[Edge.LEFT]: 0,
[Edge.RIGHT]: 0,
[Edge.TOP]: 0,
[Edge.BOTTOM]: 0,
};

ngOnInit(): void {
requireNonNull(this.imageData);

let targetWidth = this.imageData.width;
let targetScale = this.imageData.width;

if (targetWidth === 0) {
throw new Error('targetWidth is 0');
if (targetScale === 0) {
throw new Error('targetScale is 0');
}

while (targetWidth > this.maxTargetWidth) {
targetWidth /= 2;
while (targetScale > this.maxTargetScale) {
targetScale /= 2;
}
while (targetWidth < this.minTargetWidth) {
targetWidth *= 2;
while (targetScale < this.minTargetScale) {
targetScale *= 2;
}
this.targetWidth = Math.round(targetWidth);
this.targetScale = Math.round(targetScale);
}

ngAfterViewInit(): void {
Expand All @@ -107,36 +135,84 @@ export class PreprocessComponent
Math.max(
1,
Math.min(
(window.innerWidth / this.imageData.width) * 0.9,
(window.innerHeight / this.imageData.height) * 0.9,
(window.innerWidth - 200) / this.croppedWidth,
(window.innerHeight - 200) / this.croppedHeight,
25
)
)
);
this.canvas.nativeElement.style.width = `${this.imageData.width * zoom}px`;
this.canvas.nativeElement.style.height = `${
this.imageData.height * zoom
}px`;

const targetHeight = this.targetHeight;
this.ctx.canvas.width = this.targetWidth;
this.ctx.canvas.height = targetHeight;
this.canvas.nativeElement.style.width = `${this.croppedWidth * zoom}px`;
this.canvas.nativeElement.style.height = `${this.croppedHeight * zoom}px`;

this.ctx.canvas.width = this.croppedWidth;
this.ctx.canvas.height = this.croppedHeight;
this.ctx.fillStyle = '#ffffff';
this.ctx.fillRect(0, 0, this.targetWidth, targetHeight);
this.ctx.fillRect(0, 0, this.croppedWidth, this.croppedHeight);

// TODO: To reduce GC and improve performance, reuse one ImageData and change
// the its size.
const scaledImageData = new EditableImageData(
this.ctx.getImageData(0, 0, this.targetWidth, targetHeight)
new ImageData(this.scaledWidth, this.scaledHeight)
);

downscale(this.imageData, scaledImageData, this.targetColors);
this.ctx.putImageData(scaledImageData.imageData, 0, 0);

this.ctx.putImageData(
scaledImageData.imageData,
-this.crops[Edge.LEFT],
-this.crops[Edge.TOP],
0,
0,
this.scaledWidth,
this.scaledHeight
);
}

expand(edge: Edge) {
if (this.canExpand(edge)) {
this.crops[edge]--;
this.ngOnChanges();
}
}

shrink(edge: Edge) {
if (this.canShrink(edge)) {
this.crops[edge]++;
this.ngOnChanges();
}
}

canExpand(edge: Edge) {
return this.crops[edge] > 0;
}

canShrink(edge: Edge) {
if (edge === Edge.TOP || edge === Edge.BOTTOM) {
return (
this.crops[Edge.TOP] + this.crops[Edge.BOTTOM] < this.scaledHeight - 1
);
} else {
return (
this.crops[Edge.LEFT] + this.crops[Edge.RIGHT] < this.scaledWidth - 1
);
}
}

resizeTo(scaledWidth: number) {
this.targetScale = scaledWidth;
this.crops = {
[Edge.LEFT]: 0,
[Edge.RIGHT]: 0,
[Edge.TOP]: 0,
[Edge.BOTTOM]: 0,
};
this.ngOnChanges();
}

ngOnDestroy(): void {
this.imageDataOnDestroy.next(
new EditableImageData(
this.ctx.getImageData(0, 0, this.targetWidth, this.targetHeight)
this.ctx.getImageData(0, 0, this.croppedWidth, this.croppedHeight)
)
);
}
Expand Down