Skip to content

Commit

Permalink
Implement FileReader API
Browse files Browse the repository at this point in the history
Fix #4
  • Loading branch information
asamuzaK committed Feb 10, 2023
1 parent 116f473 commit 85aa44e
Show file tree
Hide file tree
Showing 4 changed files with 1,022 additions and 1 deletion.
21 changes: 21 additions & 0 deletions modules/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,24 @@ export const getType = o => Object.prototype.toString.call(o).slice(TYPE_FROM, T
* @returns {boolean} - result
*/
export const isString = o => typeof o === 'string' || o instanceof String;

/**
* sleep
*
* @param {number} msec - millisecond
* @param {boolean} doReject - reject instead of resolve
* @returns {?Function} - resolve / reject
*/
export const sleep = (msec = 0, doReject = false) => {
let func;
if (Number.isInteger(msec) && msec >= 0) {
func = new Promise((resolve, reject) => {
if (doReject) {
setTimeout(reject, msec);
} else {
setTimeout(resolve, msec);
}
});
}
return func || null;
};
277 changes: 277 additions & 0 deletions src/mjs/file-reader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
/**
* file-reader.js
* implement HTML5 FileReader API
*/

/* shared */
import { Blob, resolveObjectURL } from 'node:buffer';
import { getType, isString } from './common.js';
import textChars from '../lib/file/text-chars.json' assert { type: 'json' };

/* constants */
const DONE = 2;
const EMPTY = 0;
const LOADING = 1;
const REG_CHARSET = /^charset=([\da-z\-_]+)$/i;
const REG_MIME_DOM =
/^(?:text\/(?:ht|x)ml|application\/(?:xhtml\+)?xml|image\/svg\+xml);?/;
const REG_MIME_TEXT = /^text\/[\da-z][\da-z\-.][\da-z]+;?/i;
/**
* file reader
*
*/
export class FileReader extends EventTarget {
/* private fields */
#error;
#state;
#result;
#terminate;

/**
* construct
*/
constructor() {
super();
this.EMPTY = EMPTY;
this.LOADING = LOADING;
this.DONE = DONE;
this.#error = null;
this.#state = this.EMPTY;
this.#result = null;
this.#terminate = false;
}

/* getter */
get error() {
return this.#error;
}

get readyState() {
return this.#state;
}

get result() {
return this.#result;
}

/**
* dispatch progress event
*
* @param {string} type - event type
* @returns {Function} - super.dispatchEvent()
*/
_dispatchProgressEvent(type) {
if (isString(type)) {
type = type.trim();
if (!/abort|error|load(?:end|start)?|progress/.test(type)) {
this.#error = new DOMException('Invalid state.', 'InvalidStateError');
this._dispatchProgressEvent('error');
throw this.#error;
}
} else {
this.#error = new TypeError(`Expected String but got ${getType(type)}.`);
this._dispatchProgressEvent('error');
throw this.#error;
}
if (type === 'error' && !this.#error) {
this.#error = new Error('Unknown error.');
}
const evt = new Event(type, {
bubbles: false,
cancelable: false
});
return super.dispatchEvent(evt);
}

/**
* abort
*
* @returns {void}
*/
abort() {
this.#state = this.DONE;
this.#result = null;
this.#terminate = true;
this._dispatchProgressEvent('abort');
this._dispatchProgressEvent('loadend');
}

/**
* read blob
*
* @param {object|string} blob - blob data or blob URL
* @param {string} format - format to read as
* @param {string} encoding - character encoding
* @returns {void}
*/
async _read(blob, format, encoding = '') {
if (!((blob instanceof Blob || isString(blob)) && isString(format) &&
isString(encoding))) {
this.abort();
}
if (!this.#terminate) {
if (this.#state === this.LOADING) {
this.#error = new DOMException('Invalid state.', 'InvalidStateError');
this._dispatchProgressEvent('error');
throw this.#error;
}
this.#state = this.LOADING;
this.#result = null;
this.#error = null;
let data;
if (blob instanceof Blob) {
data = blob;
} else if (isString(blob)) {
try {
const { protocol } = new URL(blob);
if (protocol === 'blob:') {
data = resolveObjectURL(blob);
}
} catch (e) {
// fall through
}
}
if (data) {
this._dispatchProgressEvent('loadstart');
let res;
try {
const { type } = data;
const buffer = await data.arrayBuffer();
const uint8arr = new Uint8Array(buffer);
const header = type ? type.split(';') : [];
const binary = String.fromCharCode(...uint8arr);
switch (format) {
case 'arrayBuffer':
case 'buffer':
res = buffer;
break;
case 'binary':
case 'binaryString':
res = binary;
break;
case 'data':
case 'dataURL': {
if (!header.length || header[header.length - 1] !== 'base64') {
header.push('base64');
}
res = `data:${header.join(';')},${btoa(binary)}`;
break;
}
// NOTE: read only if encoding matches
case 'text': {
const textCharCodes = new Set(textChars);
if (uint8arr.every(c => textCharCodes.has(c))) {
let charset;
for (const head of header) {
if (REG_CHARSET.test(head)) {
[, charset] = REG_CHARSET.exec(head);
if (charset) {
if (/utf-?8/i.test(charset)) {
charset = 'utf8';
} else {
charset = charset.toLowerCase();
}
break;
}
}
}
if (encoding) {
if (/utf-?8/i.test(encoding)) {
encoding = 'utf8';
} else {
encoding = encoding.toLowerCase();
}
}
if (REG_MIME_DOM.test(type)) {
if ((encoding && charset && encoding === charset) ||
(!(encoding || charset)) ||
(!encoding && charset === 'utf8') ||
(encoding === 'utf8' && !charset)) {
res = binary;
}
} else if (REG_MIME_TEXT.test(type)) {
if ((encoding && charset && encoding === charset) ||
(!(encoding || charset)) ||
(!encoding && charset === 'utf8') ||
(encoding === 'utf8' && charset === 'us-ascii')) {
res = binary;
}
}
}
break;
}
default:
}
} catch (e) {
if (!this.#terminate) {
this.#error = e;
this.#state = this.DONE;
this._dispatchProgressEvent('error');
this._dispatchProgressEvent('loadend');
}
}
if (!(this.#terminate || this.#error)) {
if (res) {
this.#result = res;
this.#state = this.DONE;
this._dispatchProgressEvent('load');
this._dispatchProgressEvent('loadend');
} else {
this.abort();
}
}
} else {
this.abort();
}
}
}

/**
* read as arrayBuffer
*
* @param {object|string} blob - blob data or blob URL
* @returns {void}
*/
async readAsArrayBuffer(blob) {
await this._read(blob, 'arrayBuffer');
}

/**
* read as binary string
*
* @param {object|string} blob - blob data or blob URL
* @returns {void}
*/
async readAsBinaryString(blob) {
await this._read(blob, 'binaryString');
}

/**
* read as data URL
*
* @param {object|string} blob - blob data or blob URL
* @returns {void}
*/
async readAsDataURL(blob) {
await this._read(blob, 'dataURL');
}

/**
* read as text
*
* @param {object|string} blob - blob data or blob URL
* @param {string} encoding - encoding
* @returns {void}
*/
async readAsText(blob, encoding) {
await this._read(blob, 'text', encoding);
}
}

/* instance */
const fileReader = new FileReader();

/* export */
export {
fileReader as default
};
38 changes: 37 additions & 1 deletion test/common.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import sinon from 'sinon';

/* test */
import {
getType, isString, logErr, logMsg, logWarn, throwErr
getType, isString, logErr, logMsg, logWarn, sleep, throwErr
} from '../modules/common.js';

describe('getType', () => {
Expand Down Expand Up @@ -102,3 +102,39 @@ describe('throwErr', () => {
assert.throws(() => throwErr(e));
});
});

describe('sleep', () => {
it('should resolve even if no argument given', async () => {
const fake = sinon.fake();
const fake2 = sinon.fake();
await sleep().then(fake).catch(fake2);
assert.strictEqual(fake.callCount, 1);
assert.strictEqual(fake2.callCount, 0);
});

it('should get null if 1st argument is not integer', async () => {
const res = await sleep('foo');
assert.isNull(res);
});

it('should get null if 1st argument is not positive integer', async () => {
const res = await sleep(-1);
assert.isNull(res);
});

it('should resolve', async () => {
const fake = sinon.fake();
const fake2 = sinon.fake();
await sleep(1).then(fake).catch(fake2);
assert.strictEqual(fake.callCount, 1);
assert.strictEqual(fake2.callCount, 0);
});

it('should reject', async () => {
const fake = sinon.fake();
const fake2 = sinon.fake();
await sleep(1, true).then(fake).catch(fake2);
assert.strictEqual(fake.callCount, 0);
assert.strictEqual(fake2.callCount, 1);
});
});
Loading

0 comments on commit 85aa44e

Please sign in to comment.