Skip to content

Commit

Permalink
Handle vectors (GoogleChromeLabs#187)
Browse files Browse the repository at this point in the history
* Allow loading SVG. Fixes GoogleChromeLabs#138.

I also made the resizer vector-aware, so you can resize the image larger & stay sharp.

* Handling SVG without width/height set.

* Simplifying maths

* Doh, case sensitive
  • Loading branch information
jakearchibald authored Oct 11, 2018
1 parent 405f7ca commit abf73a2
Show file tree
Hide file tree
Showing 5 changed files with 137 additions and 32 deletions.
4 changes: 3 additions & 1 deletion src/codecs/resize/options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { bind, inputFieldValueAsNumber } from '../../lib/util';
import { ResizeOptions } from './resize';

interface Props {
isVector: Boolean;
options: ResizeOptions;
aspect: number;
onChange(newOptions: ResizeOptions): void;
Expand Down Expand Up @@ -63,7 +64,7 @@ export default class ResizerOptions extends Component<Props, State> {
this.form!.width.value = Math.round(height * this.props.aspect);
}

render({ options, aspect }: Props, { maintainAspect }: State) {
render({ options, aspect, isVector }: Props, { maintainAspect }: State) {
return (
<form ref={el => this.form = el}>
<label>
Expand All @@ -73,6 +74,7 @@ export default class ResizerOptions extends Component<Props, State> {
value={options.method}
onChange={this.onChange}
>
{isVector && <option value="vector">Vector</option>}
<option value="browser-pixelated">Browser pixelated</option>
<option value="browser-low">Browser low quality</option>
<option value="browser-medium">Browser medium quality</option>
Expand Down
58 changes: 46 additions & 12 deletions src/codecs/resize/resize.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
import { nativeResize, NativeResizeMethod } from '../../lib/util';
import { nativeResize, NativeResizeMethod, drawableToImageData } from '../../lib/util';

export function resize(data: ImageData, opts: ResizeOptions): ImageData {
function getCoverOffsets(sw: number, sh: number, dw: number, dh: number) {
const currentAspect = sw / sh;
const endAspect = dw / dh;

if (endAspect > currentAspect) {
const newSh = sw / endAspect;
const newSy = (sh - newSh) / 2;
return { sw, sh: newSh, sx: 0, sy: newSy };
}

const newSw = sh * endAspect;
const newSx = (sw - newSw) / 2;
return { sh, sw: newSw, sx: newSx, sy: 0 };
}

export function resize(data: ImageData, opts: BitmapResizeOptions): ImageData {
let sx = 0;
let sy = 0;
let sw = data.width;
let sh = data.height;

if (opts.fitMethod === 'cover') {
const currentAspect = data.width / data.height;
const endAspect = opts.width / opts.height;
if (endAspect > currentAspect) {
sh = opts.height / (opts.width / data.width);
sy = (data.height - sh) / 2;
} else {
sw = opts.width / (opts.height / data.height);
sx = (data.width - sw) / 2;
}
({ sx, sy, sw, sh } = getCoverOffsets(sw, sh, opts.width, opts.height));
}

return nativeResize(
Expand All @@ -24,18 +31,45 @@ export function resize(data: ImageData, opts: ResizeOptions): ImageData {
);
}

export function vectorResize(data: HTMLImageElement, opts: VectorResizeOptions): ImageData {
let sx = 0;
let sy = 0;
let sw = data.width;
let sh = data.height;

if (opts.fitMethod === 'cover') {
({ sx, sy, sw, sh } = getCoverOffsets(sw, sh, opts.width, opts.height));
}

return drawableToImageData(data, {
sx, sy, sw, sh,
width: opts.width, height: opts.height,
});
}

type BitmapResizeMethods = 'browser-pixelated' | 'browser-low' | 'browser-medium' | 'browser-high';

export interface ResizeOptions {
width: number;
height: number;
method: 'browser-pixelated' | 'browser-low' | 'browser-medium' | 'browser-high';
method: 'vector' | BitmapResizeMethods;
fitMethod: 'stretch' | 'cover';
}

export interface BitmapResizeOptions extends ResizeOptions {
method: BitmapResizeMethods;
}

export interface VectorResizeOptions extends ResizeOptions {
method: 'vector';
}

export const defaultOptions: ResizeOptions = {
// Width and height will always default to the image size.
// This is set elsewhere.
width: 1,
height: 1,
// This will be set to 'vector' if the input is SVG.
method: 'browser-high',
fitMethod: 'stretch',
};
58 changes: 51 additions & 7 deletions src/components/App/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { h, Component } from 'preact';

import { bind, linkRef, Fileish } from '../../lib/util';
import { bind, linkRef, Fileish, blobToImg, drawableToImageData, blobToText } from '../../lib/util';
import * as style from './style.scss';
import Output from '../Output';
import Options from '../Options';
Expand Down Expand Up @@ -45,6 +45,7 @@ type Orientation = 'horizontal' | 'vertical';
export interface SourceImage {
file: File;
data: ImageData;
vectorImage?: HTMLImageElement;
}

interface EncodedImage {
Expand Down Expand Up @@ -81,7 +82,14 @@ async function preprocessImage(
): Promise<ImageData> {
let result = source.data;
if (preprocessData.resize.enabled) {
result = resizer.resize(result, preprocessData.resize);
if (preprocessData.resize.method === 'vector' && source.vectorImage) {
result = resizer.vectorResize(
source.vectorImage,
preprocessData.resize as resizer.VectorResizeOptions,
);
} else {
result = resizer.resize(result, preprocessData.resize as resizer.BitmapResizeOptions);
}
}
if (preprocessData.quantizer.enabled) {
result = await quantizer.quantize(result, preprocessData.quantizer);
Expand Down Expand Up @@ -120,6 +128,31 @@ async function compressImage(
);
}

async function processSvg(blob: Blob): Promise<HTMLImageElement> {
// Firefox throws if you try to draw an SVG to canvas that doesn't have width/height.
// In Chrome it loads, but drawImage behaves weirdly.
// This function sets width/height if it isn't already set.
const parser = new DOMParser();
const text = await blobToText(blob);
const document = parser.parseFromString(text, 'image/svg+xml');
const svg = document.documentElement;

if (svg.hasAttribute('width') && svg.hasAttribute('height')) {
return blobToImg(blob);
}

const viewBox = svg.getAttribute('viewBox');
if (viewBox === null) throw Error('SVG must have width/height or viewBox');

const viewboxParts = viewBox.split(/\s+/);
svg.setAttribute('width', viewboxParts[2]);
svg.setAttribute('height', viewboxParts[3]);

const serializer = new XMLSerializer();
const newSource = serializer.serializeToString(document);
return blobToImg(new Blob([newSource], { type: 'image/svg+xml' }));
}

export default class App extends Component<Props, State> {
widthQuery = window.matchMedia('(min-width: 500px)');

Expand Down Expand Up @@ -228,11 +261,22 @@ export default class App extends Component<Props, State> {
async updateFile(file: File) {
this.setState({ loading: true });
try {
const data = await decodeImage(file);
let data: ImageData;
let vectorImage: HTMLImageElement | undefined;

// Special-case SVG. We need to avoid createImageBitmap because of
// https://bugs.chromium.org/p/chromium/issues/detail?id=606319.
// Also, we cache the HTMLImageElement so we can perform vector resizing later.
if (file.type === 'image/svg+xml') {
vectorImage = await processSvg(file);
data = drawableToImageData(vectorImage);
} else {
data = await decodeImage(file);
}

let newState = {
let newState: State = {
...this.state,
source: { data, file },
source: { data, file, vectorImage },
loading: false,
};

Expand All @@ -241,6 +285,7 @@ export default class App extends Component<Props, State> {
newState = cleanMerge(newState, `images.${i}.preprocessorState.resize`, {
width: data.width,
height: data.height,
method: vectorImage ? 'vector' : 'browser-high',
});
}

Expand Down Expand Up @@ -349,11 +394,10 @@ export default class App extends Component<Props, State> {
/>
{images.map((image, index) => (
<Options
source={source}
orientation={orientation}
sourceAspect={source.data.width / source.data.height}
imageIndex={index}
imageFile={image.file}
sourceImageFile={source && source.file}
downloadUrl={image.downloadUrl}
preprocessorState={image.preprocessorState}
encoderState={image.encoderState}
Expand Down
12 changes: 6 additions & 6 deletions src/components/Options/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { ResizeOptions } from '../../codecs/resize/resize';
import { PreprocessorState } from '../../codecs/preprocessors';
import FileSize from '../FileSize';
import { DownloadIcon } from '../../lib/icons';
import { SourceImage } from '../App';

const encoderOptionsComponentMap = {
[identity.type]: undefined,
Expand All @@ -62,9 +63,8 @@ const titles = {

interface Props {
orientation: 'horizontal' | 'vertical';
sourceAspect: number;
source: SourceImage;
imageIndex: number;
sourceImageFile?: File;
imageFile?: Fileish;
downloadUrl?: string;
encoderState: EncoderState;
Expand Down Expand Up @@ -129,8 +129,7 @@ export default class Options extends Component<Props, State> {

render(
{
sourceImageFile,
sourceAspect,
source,
imageIndex,
imageFile,
downloadUrl,
Expand Down Expand Up @@ -178,7 +177,8 @@ export default class Options extends Component<Props, State> {
</label>
{preprocessorState.resize.enabled &&
<ResizeOptionsComponent
aspect={sourceAspect}
isVector={Boolean(source.vectorImage)}
aspect={source.data.width / source.data.height}
options={preprocessorState.resize}
onChange={this.onResizeOptionsChange}
/>
Expand Down Expand Up @@ -223,7 +223,7 @@ export default class Options extends Component<Props, State> {
increaseClass={style.increase}
decreaseClass={style.decrease}
file={imageFile}
compareTo={imageFile === sourceImageFile ? undefined : sourceImageFile}
compareTo={imageFile === source.file ? undefined : source.file}
/>

{(downloadUrl && imageFile) && (
Expand Down
37 changes: 31 additions & 6 deletions src/lib/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ export function blobToArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
return new Response(blob).arrayBuffer();
}

export function blobToText(blob: Blob): Promise<string> {
return new Response(blob).text();
}

const magicNumberToMimeType = new Map<RegExp, string>([
[/^%PDF-/, 'application/pdf'],
[/^GIF87a/, 'image/gif'],
Expand Down Expand Up @@ -122,7 +126,7 @@ export async function sniffMimeType(blob: Blob): Promise<string> {
return '';
}

async function blobToImg(blob: Blob): Promise<HTMLImageElement> {
export async function blobToImg(blob: Blob): Promise<HTMLImageElement> {
const url = URL.createObjectURL(blob);

try {
Expand All @@ -147,16 +151,37 @@ async function blobToImg(blob: Blob): Promise<HTMLImageElement> {
}
}

function drawableToImageData(drawable: ImageBitmap | HTMLImageElement): ImageData {
interface DrawableToImageDataOptions {
width?: number;
height?: number;
sx?: number;
sy?: number;
sw?: number;
sh?: number;
}

export function drawableToImageData(
drawable: ImageBitmap | HTMLImageElement,
opts: DrawableToImageDataOptions = {},
): ImageData {
const {
width = drawable.width,
height = drawable.height,
sx = 0,
sy = 0,
sw = drawable.width,
sh = drawable.height,
} = opts;

// Make canvas same size as image
const canvas = document.createElement('canvas');
canvas.width = drawable.width;
canvas.height = drawable.height;
canvas.width = width;
canvas.height = height;
// Draw image onto canvas
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Could not create canvas context');
ctx.drawImage(drawable, 0, 0);
return ctx.getImageData(0, 0, drawable.width, drawable.height);
ctx.drawImage(drawable, sx, sy, sw, sh, 0, 0, width, height);
return ctx.getImageData(0, 0, width, height);
}

export async function nativeDecode(blob: Blob): Promise<ImageData> {
Expand Down

0 comments on commit abf73a2

Please sign in to comment.