| // Copyright 2023 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import * as Platform from '../../core/platform/platform.js'; |
| |
| import { contentAsDataURL, type DeferredContent } from './ContentProvider.js'; |
| import {Text} from './Text.js'; |
| |
| /** |
| * This class is a small wrapper around either raw binary or text data. |
| * As the binary data can actually contain textual data, we also store the |
| * MIME type and if applicable, the charset. |
| * |
| * This information should be generally kept together, as interpreting text |
| * from raw bytes requires an encoding. |
| * |
| * Note that we only rarely have to decode text ourselves in the frontend, |
| * this is mostly handled by the backend. There are cases though (e.g. SVG, |
| * or streaming response content) where we receive text data in |
| * binary (base64-encoded) form. |
| * |
| * The class only implements decoding. We currently don't have a use-case |
| * to re-encode text into base64 bytes using a specified charset. |
| */ |
| export class ContentData { |
| readonly mimeType: string; |
| readonly charset: string; |
| |
| #contentAsBase64?: string; |
| #contentAsText?: string; |
| |
| #contentAsTextObj?: Text; |
| |
| constructor(data: string, isBase64: boolean, mimeType: string, charset?: string) { |
| this.charset = charset || 'utf-8'; |
| if (isBase64) { |
| this.#contentAsBase64 = data; |
| } else { |
| this.#contentAsText = data; |
| } |
| |
| this.mimeType = mimeType; |
| if (!this.mimeType) { |
| // Tests or broken requests might pass an empty/undefined mime type. Fallback to |
| // "default" mime types. |
| this.mimeType = isBase64 ? 'application/octet-stream' : 'text/plain'; |
| } |
| } |
| |
| /** |
| * Returns the data as base64. |
| * |
| * @throws if this `ContentData` was constructed from text content. |
| */ |
| get base64(): string { |
| if (this.#contentAsBase64 === undefined) { |
| throw new Error('Encoding text content as base64 is not supported'); |
| } |
| return this.#contentAsBase64; |
| } |
| |
| /** |
| * Returns the content as text. If this `ContentData` was constructed with base64 |
| * encoded bytes, it will use the provided charset to attempt to decode the bytes. |
| * |
| * @throws if `mimeType` is not a text type. |
| */ |
| get text(): string { |
| if (this.#contentAsText !== undefined) { |
| return this.#contentAsText; |
| } |
| |
| if (!this.isTextContent) { |
| throw new Error('Cannot interpret binary data as text'); |
| } |
| |
| const binaryString = window.atob(this.#contentAsBase64 as string); |
| const len = binaryString.length; |
| const bytes = new Uint8Array(len); |
| for (let i = 0; i < len; i++) { |
| bytes[i] = binaryString.charCodeAt(i); |
| } |
| |
| this.#contentAsText = new TextDecoder(this.charset).decode(bytes); |
| return this.#contentAsText; |
| } |
| |
| /** @returns true, if this `ContentData` was constructed from text content or the mime type indicates text that can be decoded */ |
| get isTextContent(): boolean { |
| return this.#createdFromText || Platform.MimeType.isTextType(this.mimeType); |
| } |
| |
| get isEmpty(): boolean { |
| // Don't trigger unnecessary decoding. Only check if both of the strings are empty. |
| return !Boolean(this.#contentAsBase64) && !Boolean(this.#contentAsText); |
| } |
| |
| get createdFromBase64(): boolean { |
| return this.#contentAsBase64 !== undefined; |
| } |
| |
| get #createdFromText(): boolean { |
| return this.#contentAsBase64 === undefined; |
| } |
| |
| /** |
| * Returns the text content as a `Text` object. The returned object is always the same to |
| * minimize the number of times we have to calculate the line endings array. |
| * |
| * @throws if `mimeType` is not a text type. |
| */ |
| get textObj(): Text { |
| if (this.#contentAsTextObj === undefined) { |
| this.#contentAsTextObj = new Text(this.text); |
| } |
| return this.#contentAsTextObj; |
| } |
| |
| /** |
| * @returns True, iff the contents (base64 or text) are equal. |
| * Does not compare mime type and charset, but will decode base64 data if both |
| * mime types indicate that it's text content. |
| */ |
| contentEqualTo(other: ContentData): boolean { |
| if (this.#contentAsBase64 !== undefined && other.#contentAsBase64 !== undefined) { |
| return this.#contentAsBase64 === other.#contentAsBase64; |
| } |
| if (this.#contentAsText !== undefined && other.#contentAsText !== undefined) { |
| return this.#contentAsText === other.#contentAsText; |
| } |
| if (this.isTextContent && other.isTextContent) { |
| return this.text === other.text; |
| } |
| return false; |
| } |
| |
| asDataUrl(): string|null { |
| // To keep with existing behavior we prefer to return the content |
| // encoded if that is how this ContentData was constructed with. |
| if (this.#contentAsBase64 !== undefined) { |
| const charset = this.isTextContent ? this.charset : null; |
| return contentAsDataURL(this.#contentAsBase64, this.mimeType ?? '', true, charset); |
| } |
| return contentAsDataURL(this.text, this.mimeType ?? '', false, 'utf-8'); |
| } |
| |
| /** |
| * @deprecated Used during migration from `DeferredContent` to `ContentData`. |
| */ |
| asDeferedContent(): DeferredContent { |
| // To prevent encoding mistakes, we'll return text content already decoded. |
| if (this.isTextContent) { |
| return {content: this.text, isEncoded: false}; |
| } |
| if (this.#contentAsText !== undefined) { |
| // Unknown text mime type, this should not really happen. |
| return {content: this.#contentAsText, isEncoded: false}; |
| } |
| if (this.#contentAsBase64 !== undefined) { |
| return {content: this.#contentAsBase64, isEncoded: true}; |
| } |
| throw new Error('Unreachable'); |
| } |
| |
| static isError(contentDataOrError: ContentDataOrError): contentDataOrError is {error: string} { |
| return 'error' in contentDataOrError; |
| } |
| |
| /** @returns `value` if the passed `ContentDataOrError` is an error, or the text content otherwise */ |
| static textOr<T>(contentDataOrError: ContentDataOrError, value: T): string|T { |
| if (ContentData.isError(contentDataOrError)) { |
| return value; |
| } |
| return contentDataOrError.text; |
| } |
| |
| /** @returns an empty 'text/plain' content data if the passed `ContentDataOrError` is an error, or the content data itself otherwise */ |
| static contentDataOrEmpty(contentDataOrError: ContentDataOrError): ContentData { |
| if (ContentData.isError(contentDataOrError)) { |
| return EMPTY_TEXT_CONTENT_DATA; |
| } |
| return contentDataOrError; |
| } |
| |
| /** |
| * @deprecated Used during migration from `DeferredContent` to `ContentData`. |
| */ |
| static asDeferredContent(contentDataOrError: ContentDataOrError): DeferredContent { |
| if (ContentData.isError(contentDataOrError)) { |
| return {error: contentDataOrError.error, content: null, isEncoded: false}; |
| } |
| return contentDataOrError.asDeferedContent(); |
| } |
| } |
| |
| export const EMPTY_TEXT_CONTENT_DATA = new ContentData('', /* isBase64 */ false, 'text/plain'); |
| |
| export type ContentDataOrError = ContentData|{error: string}; |