Skip to content
Snippets Groups Projects
Commit 97e85827 authored by Benedikt's avatar Benedikt
Browse files

Merge branch 'devel' into main

parents 576a219c d8e9c808
Branches
No related tags found
No related merge requests found
Pipeline #53169 passed
Showing
with 1269 additions and 34 deletions
...@@ -22,6 +22,4 @@ venv ...@@ -22,6 +22,4 @@ venv
.vscode/ .vscode/
dsiUnits-js/node_modules/.bin/
dsiUnits-js/node_modules/ dsiUnits-js/node_modules/
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Pytest: Debugger",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/venv/bin/pytest",
"console": "integratedTerminal",
"justMyCode": false,
"args": ["--no-cov"],
}
]
}
\ No newline at end of file
...@@ -3,6 +3,11 @@ ...@@ -3,6 +3,11 @@
This library converts D-SI unit strings to Latex. This library converts D-SI unit strings to Latex.
And is able to perform math operations *, / and power with the D-SI units as well as checken weather teh can be converted into each other with scalar multiplication And is able to perform math operations *, / and power with the D-SI units as well as checken weather teh can be converted into each other with scalar multiplication
## Javascript version
The folder [dsiUnits-js](/dsiUnits-js) contains an Javascript version of this libary that can be used for a D-SI-Unit-Input Element with autosugestion. And a dsiUnit CLass that supports parsing and rendering (HTML converion) of the dsi Units.
## Installation ## Installation
```bash ```bash
......
# DSI Units JS Library
**DSI Units JS Library** is a JavaScript library for parsing, rendering, and interactively editing D‑SI unit strings.
This library is a mainly LMM-generated version of the corresponding [dsiunits](https://pypi.org/project/dsiunits/) Python package. (Repository: [digitaldynamicmeasurement/dsiUnits](https://gitlab1.ptb.de/digitaldynamicmeasurement/dsiUnits)).
The library includes two main components:
- **DSIUnit** – a class for parsing and rendering D‑SI unit strings.
- **DSIUnitInput** – a custom web component (`<dsi-unit-input>`) that provides an auto‑complete interactive input field for D‑SI units.
## License
This project is licensed under the [LGPL-2.1](../LICENSE) License.
## Installation
You will be able to install the library via npm in near future:
```bash
npm install dsiunits-js
```
Or clone the repository and bundle the code with your preferred bundler (Webpack, Rollup, etc.).
## Usage
### Importing the Library
For ES modules, import the classes as follows:
```js
import { DSIUnit } from "dsiunits-js/src/dsiUnit.js";
import "dsiunits-js/src/dsiUnitInput.js"; // This registers the <dsi-unit-input> custom element.
```
### Using the DSIUnit Class
The `DSIUnit` class is used to parse and render a D‑SI unit string.
#### Constructor
```js
const unit = new DSIUnit("\\metre\\tothe{2}");
```
- **Parameters:**
- `dsiString` *(string)*: The raw D‑SI unit string (e.g., `"\metre\tothe{2}"`).
#### Properties
- `dsiString` – The original D‑SI string.
- `tree` – The parsed representation of the D‑SI unit.
- `warnings` – An array of warnings encountered during parsing.
- `nonDsiUnit` – A boolean indicating whether the string was marked as a non‑DSI unit.
- `valid` – A boolean indicating whether the parsed unit is valid.
- `scaleFactor` – The overall scale factor (default: 1.0).
#### Methods
- **toHTML(options)**
Renders the unit as an HTML string.
**Parameters:**
- `options` *(object, optional)*:
- `wrapper` *(string)*: String to wrap the rendered output.
- `prefix` *(string)*: String to prepend.
- `suffix` *(string)*: String to append.
- `oneLine` *(boolean)*: If `true`, renders a compact, single‑line version (fractions with “/” and roots in inline format).
**Example:**
```js
console.log(unit.toHTML({ oneLine: true }));
```
- **toString()**
Returns the canonical D‑SI string representation.
```js
console.log(unit.toString());
```
### Using the DSIUnitInput Web Component
The `DSIUnitInput` custom element provides an interactive auto‑complete input for D‑SI unit strings.
#### HTML Usage
```html
<dsi-unit-input suggestions-list="kilo,mega,milli,metre,second,per,tothe"></dsi-unit-input>
```
#### Features
- **Auto‑Completion:**
As you type after a backslash (`\\`), suggestions are provided based on context:
- If a prefix is accepted (e.g., `\milli`), only allowed units are suggested.
- If a unit is accepted, all allowed tokens (including `\per` and `\tothe`) are suggested.
- Arrow keys navigate suggestions; Tab, Enter, or Right Arrow accept a suggestion.
- **Focus/Blur Behavior:**
When the field loses focus, the rendered unit (using `DSIUnit.toHTML({ oneLine: true })`) is shown. Clicking the rendered output restores the raw input for editing.
- **Customization:**
Override the allowed tokens by setting the `suggestions-list` attribute with a comma‑separated list.
#### Properties
- **value**
Returns the current raw D‑SI unit string.
- **live**
A boolean property (default: `true`) to enable/disable live updating.
{
"presets": [
["@babel/preset-env", { "targets": { "node": "current" } }]
]
}
export default {
testEnvironment: "jsdom",
testEnvironmentOptions: {}
};
\ No newline at end of file
{
"name": "dsiunits-js",
"version": "0.9.1",
"type": "module",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"scripts": {
"build": "rollup -c",
"test": "jest"
},
"devDependencies": {
"@babel/core": "^7.0.0",
"@babel/preset-env": "^7.0.0",
"@rollup/plugin-commonjs": "^21.0.0",
"@rollup/plugin-node-resolve": "^15.0.0",
"babel-jest": "^27.0.0",
"jest": "^28.1.3",
"jest-environment-jsdom": "^28.1.3",
"rollup": "^2.79.2"
}
}
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
export default {
input: 'src/index.js', // Entry point (see below)
output: [
{
file: 'dist/index.esm.js',
format: 'esm',
sourcemap: true
},
{
file: 'dist/index.cjs.js',
format: 'cjs',
sourcemap: true
}
],
plugins: [
resolve(),
commonjs()
]
};
// dsiParser.js
import { DSIUnitNode } from './dsiUnitNode.js';
import { dsiPrefixesHTML, dsiUnitsHTML, dsiKeyWords } from './unitStrings.js';
class DSIParser {
constructor() {
// Default options
this.createPerByDivision = true;
this.maxDenominator = 10000;
// Default wrappers for HTML output are not needed since we output directly HTML
}
// Singleton pattern
static getInstance() {
if (!DSIParser.instance) {
DSIParser.instance = new DSIParser();
}
return DSIParser.instance;
}
parse(dsiString) {
const warnings = [];
// Check for non-DSI marker
if (dsiString.length > 0 && dsiString[0] === '|') {
warnings.push("Parsing a correctly marked non D-SI unit!");
return {
original: dsiString,
tree: [[new DSIUnitNode('', dsiString.slice(1), '', false)]],
warnings,
nonDsiUnit: true
};
}
// Replace double backslashes with single backslash
while (dsiString.includes('\\\\')) {
warnings.push(`Double backslash found in string, treating as one backslash: «${dsiString}»`);
dsiString = dsiString.replace(/\\\\/g, '\\');
}
if (dsiString === "") {
warnings.push("Given D-SI string is empty!");
return {
original: 'NULL',
tree: [[new DSIUnitNode('', 'NULL', '', false)]],
warnings,
nonDsiUnit: true
};
}
// Parse fractions: split at "\per" (literal string "\per")
let modifiedStr = dsiString.replace('percent', 'prozent');
let parts = modifiedStr.split('\\per').map(s => s.replace('prozent', 'percent'));
// Check for empty fraction parts
parts = parts.filter(part => {
if (part.length === 0) {
warnings.push(`The dsi string contains a \\per missing a numerator or denominator! Given string: ${dsiString}`);
return false;
}
return true;
});
if (parts.length > 2) {
warnings.push(`The dsi string contains more than one \\per, does not match specs! Given string: ${dsiString}`);
}
// For each fraction, parse the fractionless part.
const tree = parts.map(part => {
return this._parseFractionlessDsi(part, warnings);
});
return {
original: dsiString,
tree,
warnings,
nonDsiUnit: false
};
}
_parseFractionlessDsi(dsiString, warnings) {
let items = dsiString.split('\\');
if (items[0] === '') {
items.shift();
} else {
warnings.push(`String should start with a backslash, string given was «${dsiString}»`);
}
const nodes = [];
while (items.length) {
let prefix = '';
let unit = '';
let exponent = '';
let valid = true;
if (items.length && items[0] in dsiPrefixesHTML) {
prefix = items.shift();
}
if (items.length) {
unit = items.shift();
} else {
warnings.push(`This D-SI unit seems to be missing the base unit! \\${prefix}`);
valid = false;
}
if (items.length && /^tothe\{[^{}]*\}$/.test(items[0])) {
const expToken = items.shift();
const match = expToken.match(/tothe\{([^{}]+)\}/);
if (match) {
exponent = match[1];
if (isNaN(Number(exponent))) {
warnings.push(`The exponent «${exponent}» is not a number!`);
valid = false;
}
}
}
// If a unit token was provided but is not valid, warn and mark invalid.
if (unit === '') {
warnings.push(`This D-SI unit seems to be missing the base unit! \\${prefix}${exponent ? '\\tothe{' + exponent + '}' : ''}`);
valid = false;
} else if (!(unit in dsiUnitsHTML)) {
warnings.push(`The identifier «${unit}» does not match any D-SI units!`);
valid = false;
}
nodes.push(new DSIUnitNode(prefix, unit, exponent, valid));
}
return nodes;
}
}
export default DSIParser;
// src/dsiUnit.js
import DSIParser from './dsiParser.js';
export class DSIUnit {
constructor(dsiString) {
const parser = DSIParser.getInstance();
const result = parser.parse(dsiString);
this.dsiString = result.original;
this.tree = result.tree;
this.warnings = result.warnings;
this.nonDsiUnit = result.nonDsiUnit;
// For non-DSI units (or empty string), treat them as "valid" for rendering purposes.
this.valid = this.nonDsiUnit ? true : (this.warnings.length === 0);
this.scaleFactor = 1.0;
}
toHTML(options = {}) {
let htmlOutput = '';
if (this.nonDsiUnit) {
const content = `<span class="dsi-nonunit">${this.dsiString.startsWith('|') ? this.dsiString.slice(1) : this.dsiString}</span>`;
htmlOutput = content;
} else if (this.tree.length === 1) {
htmlOutput = this.tree[0].map(node => node.toHTML(options)).join(options.oneLine ? " " : '<span class="dsi-mul">&nbsp;</span>');
} else if (this.tree.length === 2) {
if (options.oneLine) {
const numerator = this.tree[0].map(node => node.toHTML(options)).join(" ");
const denominator = this.tree[1].map(node => node.toHTML(options)).join(" ");
htmlOutput = `${numerator} / ${denominator}`;
} else {
const numerator = this.tree[0].map(node => node.toHTML(options)).join('<span class="dsi-mul">&nbsp;</span>');
const denominator = this.tree[1].map(node => node.toHTML(options)).join('<span class="dsi-mul">&nbsp;</span>');
htmlOutput = `<span class="dsi-fraction"><span class="dsi-numerator">${numerator}</span><span class="dsi-denom">${denominator}</span></span>`;
}
} else {
htmlOutput = this.tree.map(frac => frac.map(node => node.toHTML(options)).join(options.oneLine ? " " : '<span class="dsi-mul">&nbsp;</span>'))
.join(options.oneLine ? " / " : '<span class="dsi-invalid">/</span>');
}
if (this.scaleFactor !== 1.0) {
htmlOutput = `<span class="dsi-scale">${this.scaleFactor}&middot;</span>` + htmlOutput;
}
if (!this.nonDsiUnit && !this.valid) {
htmlOutput = `<span class="dsi-error">${htmlOutput}</span>`;
}
let tooltip = "";
if (!this.nonDsiUnit && this.warnings && this.warnings.length > 0) {
const tooltipText = this.warnings.map(w => w.replace(/\\\\/g, '\\')).join("; ");
tooltip = ` title="${tooltipText}"`;
}
return `<span class="dsi-unit-wrapper"${tooltip}>${htmlOutput}</span>`;
}
toString() {
if (this.nonDsiUnit) return '|' + this.dsiString;
return this.tree.map(frac => frac.map(node => node.toString()).join('')).join('\\per');
}
}
// src/dsiUnitInput.js
import { DSIUnit } from "./dsiUnit.js";
import { dsiPrefixesHTML, dsiUnitsHTML, dsiKeyWords } from "./unitStrings.js";
// Build a default suggestion list from prefixes, units, and keywords.
const defaultAllowedTokens = [
...Object.keys(dsiPrefixesHTML),
...Object.keys(dsiUnitsHTML),
"per",
"tothe"
].sort();
export class DSIUnitInput extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
// Internal state.
this._rawValue = "";
this.liveUpdate = true;
this.allowedTokens = defaultAllowedTokens;
this.selectedSuggestionIndex = -1;
this.lastAcceptedToken = ""; // Tracks the last accepted token
// Create elements.
this.input = document.createElement("input");
this.input.type = "text";
this.input.id = "dsiInput";
this.suggestions = document.createElement("div");
this.suggestions.className = "autocomplete-list";
this.suggestions.style.display = "none";
this.display = document.createElement("div"); // Rendered output on blur.
this.display.id = "dsiDisplay";
this.display.style.cursor = "text"; // Clickable for editing.
// Container for input and suggestions.
const container = document.createElement("div");
container.style.position = "relative";
container.appendChild(this.input);
container.appendChild(this.suggestions);
container.appendChild(this.display);
// Append styles.
const style = document.createElement("style");
style.textContent = `
#dsiInput {
width: 400px;
font-family: monospace;
padding: 4px;
box-sizing: border-box;
}
.autocomplete-list {
position: absolute;
border: 1px solid #ccc;
background: #fff;
max-height: 150px;
overflow-y: auto;
width: 400px;
z-index: 100;
}
.autocomplete-item {
padding: 4px;
cursor: pointer;
}
.autocomplete-item.selected {
background-color: #bde4ff;
}
#dsiDisplay {
margin-top: 5px;
padding: 4px;
border: 1px solid #ccc;
display: none;
width: 400px;
font-family: monospace;
}
`;
this.shadowRoot.append(style, container);
// Bind event handlers.
this.onInput = this.onInput.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.onBlur = this.onBlur.bind(this);
this.onFocus = this.onFocus.bind(this);
}
static get observedAttributes() {
return ["suggestions-list"];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === "suggestions-list") {
if (newValue) {
this.allowedTokens = newValue.split(",").map(s => s.trim());
} else {
this.allowedTokens = defaultAllowedTokens;
}
}
}
connectedCallback() {
const suggestionsAttr = this.getAttribute("suggestions-list");
if (suggestionsAttr) {
this.allowedTokens = suggestionsAttr.split(",").map(s => s.trim());
}
this.input.addEventListener("input", this.onInput);
this.input.addEventListener("keydown", this.onKeyDown);
this.input.addEventListener("blur", this.onBlur);
this.input.addEventListener("focus", this.onFocus);
this.display.addEventListener("click", this.onFocus);
}
disconnectedCallback() {
this.input.removeEventListener("input", this.onInput);
this.input.removeEventListener("keydown", this.onKeyDown);
this.input.removeEventListener("blur", this.onBlur);
this.input.removeEventListener("focus", this.onFocus);
this.display.removeEventListener("click", this.onFocus);
}
// Determines contextual suggestions.
getContextualSuggestions() {
const value = this.input.value;
// Trim the value to remove extra whitespace.
const tokens = value.trim().split("\\").filter(t => t !== "");
// If no complete tokens, return all.
if (tokens.length === 0) {
return this.allowedTokens;
}
// If a last accepted token exists and is a prefix, use that.
if (this.lastAcceptedToken && (this.lastAcceptedToken in dsiPrefixesHTML)) {
return Object.keys(dsiUnitsHTML);
}
// Otherwise, use the previous token from the current input.
const prevToken = tokens.length >= 2 ? tokens[tokens.length - 2] : "<empty>";
if (prevToken in dsiPrefixesHTML) {
return Object.keys(dsiUnitsHTML);
} else if (prevToken in dsiUnitsHTML) {
if (value.includes("\\per")) {
return this.allowedTokens.filter(token => token !== "per");
}
return this.allowedTokens;
} else if (prevToken === "per" || prevToken === "tothe") {
return this.allowedTokens;
} else {
return this.allowedTokens;
}
}
onInput(e) {
this._rawValue = this.input.value;
// Auto-insert a backslash after a complete token if space is typed.
if (this.input.value.endsWith(" ")) {
const trimmed = this.input.value.trimEnd();
if (!trimmed.endsWith("\\")) {
this.input.value = trimmed + "\\";
this._rawValue = this.input.value;
}
}
this.selectedSuggestionIndex = -1;
this.updateSuggestions();
if (this.liveUpdate) {
this.renderOutput();
}
}
updateSuggestions() {
const cursorPos = this.input.selectionStart;
const value = this.input.value;
// Do not show suggestions if caret is inside tothe braces.
const regexTothe = /\\tothe\{([^}]*)$/;
const substring = value.substring(0, cursorPos);
const matchTothe = substring.match(regexTothe);
if (matchTothe) {
console.log("State: inside tothe braces → no suggestions");
this.suggestions.style.display = "none";
return;
}
// Capture current token (which may be empty).
const regex = /\\([a-zA-Z]*)$/;
const match = substring.match(regex);
let currentToken = "";
if (match) {
currentToken = match[1];
}
// If current token exactly matches a known prefix, auto-complete it.
if (currentToken && (currentToken in dsiPrefixesHTML)) {
console.log("Token complete: using accepted prefix", currentToken);
this.lastAcceptedToken = currentToken;
// Auto-append trailing backslash if not present.
if (!value.endsWith("\\")) {
this.input.value = value + "\\";
this._rawValue = this.input.value;
}
currentToken = "";
}
const tokens = value.trim().split("\\").filter(t => t !== "");
let prevToken = tokens.length >= 2 ? tokens[tokens.length - 2] : (this.lastAcceptedToken || "<empty>");
const contextSuggestions = this.getContextualSuggestions();
let filtered = currentToken === ""
? contextSuggestions
: contextSuggestions.filter(token => token.startsWith(currentToken));
if (filtered.length === 0) {
console.log("State: prevToken =", prevToken, "currentToken =", currentToken, "→ No suggestions (filtered empty)");
this.suggestions.style.display = "none";
return;
}
this.selectedSuggestionIndex = 0;
console.log("State: prevToken =", prevToken, "currentToken =", currentToken, "contextSuggestions =", contextSuggestions, "filtered =", filtered);
this.showSuggestions(filtered, value.length - currentToken.length);
}
showSuggestions(list, tokenStartIndex) {
this.suggestions.innerHTML = "";
if (list.length === 0) {
this.suggestions.style.display = "none";
return;
}
list.forEach((token, index) => {
const item = document.createElement("div");
item.textContent = "\\" + token;
item.className = "autocomplete-item";
if (index === this.selectedSuggestionIndex) {
item.classList.add("selected");
}
item.addEventListener("mousedown", (e) => {
e.preventDefault();
this.acceptSuggestion(token, tokenStartIndex);
});
this.suggestions.appendChild(item);
});
this.suggestions.style.display = "block";
}
onKeyDown(e) {
if (this.suggestions.style.display !== "none") {
const items = this.suggestions.querySelectorAll(".autocomplete-item");
if (e.key === "ArrowDown") {
e.preventDefault();
if (items.length > 0) {
this.selectedSuggestionIndex = (this.selectedSuggestionIndex + 1) % items.length;
this.updateSuggestionSelection();
}
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (items.length > 0) {
this.selectedSuggestionIndex = (this.selectedSuggestionIndex - 1 + items.length) % items.length;
this.updateSuggestionSelection();
}
} else if (["Enter", "Tab", "ArrowRight"].includes(e.key)) {
if (this.selectedSuggestionIndex !== -1) {
e.preventDefault();
const selectedItem = items[this.selectedSuggestionIndex];
const token = selectedItem.textContent.slice(1); // remove leading backslash
const tokenStartIndex = this.input.value.lastIndexOf("\\");
this.acceptSuggestion(token, tokenStartIndex);
}
}
}
}
updateSuggestionSelection() {
const items = this.suggestions.querySelectorAll(".autocomplete-item");
items.forEach((item, idx) => {
if (idx === this.selectedSuggestionIndex) {
item.classList.add("selected");
} else {
item.classList.remove("selected");
}
});
}
acceptSuggestion(token, tokenStartIndex) {
const value = this.input.value;
const before = value.substring(0, tokenStartIndex);
const after = value.substring(this.input.selectionStart);
if (token === "tothe") {
this.input.value = before + "\\" + token + "{}" + after;
this._rawValue = this.input.value;
this.lastAcceptedToken = token;
const newPos = before.length + token.length + 2;
this.input.setSelectionRange(newPos, newPos);
} else {
this.input.value = before + "\\" + token + after;
this._rawValue = this.input.value;
this.lastAcceptedToken = token;
}
this.suggestions.style.display = "none";
this.renderOutput();
}
renderOutput() {
try {
const unit = new DSIUnit(this.input.value);
this.display.innerHTML = unit.toHTML({ oneLine: true });
if (unit.warnings && unit.warnings.length > 0) {
this.display.title = unit.warnings.join("; ");
} else {
this.display.removeAttribute("title");
}
} catch (err) {
this.display.innerHTML = `<span style="color:red;">Error: ${err.message}</span>`;
}
}
onBlur(e) {
this.renderOutput();
this.input.style.display = "none";
this.display.style.display = "block";
this.suggestions.style.display = "none";
}
onFocus(e) {
this.input.style.display = "block";
this.display.style.display = "none";
this.input.value = this._rawValue;
this.input.focus();
this.input.setSelectionRange(this.input.value.length, this.input.value.length);
}
get value() {
return this._rawValue;
}
set live(value) {
this.liveUpdate = Boolean(value);
}
}
customElements.define("dsi-unit-input", DSIUnitInput);
// src/dsiUnitNode.js
import { dsiPrefixesHTML, dsiUnitsHTML } from "./unitStrings.js";
export class DSIUnitNode {
constructor(prefix, unit, exponent = '', valid = true, scaleFactor = 1.0) {
this.prefix = prefix;
this.unit = unit;
this.valid = valid;
if (exponent === '' || exponent === '1') {
this.exponent = 1;
} else {
const numExp = Number(exponent);
this.exponent = isNaN(numExp) ? exponent : numExp;
}
this.scaleFactor = scaleFactor;
}
toHTML(options = {}) {
let prefixHTML = '';
if (this.prefix) {
if (this.prefix in dsiPrefixesHTML) {
prefixHTML = `<span class="dsi-unit">${dsiPrefixesHTML[this.prefix]}</span>`;
} else {
prefixHTML = `<span class="dsi-unit"><span class="dsi-invalid">${this.prefix}</span></span>`;
}
}
let unitHTML = '';
if (this.unit) {
if (this.unit in dsiUnitsHTML) {
unitHTML = `<span class="dsi-unit">${dsiUnitsHTML[this.unit]}</span>`;
} else {
unitHTML = `<span class="dsi-unit"><span class="dsi-invalid">${this.unit}</span></span>`;
}
}
let baseHTML = prefixHTML + unitHTML;
if (this.exponent !== 1) {
if (typeof this.exponent === 'number') {
if (Math.abs(this.exponent - 0.5) < 0.0001) {
// Square root.
return options.oneLine ? `√(${baseHTML})`
: `<span class="dsi-root"><span class="dsi-root-symbol">√</span><span class="dsi-root-content">${baseHTML}</span></span>`;
} else if (Math.abs(this.exponent - 0.333333) < 0.0001) {
// Cube root.
return options.oneLine ? `∛(${baseHTML})`
: `<span class="dsi-root"><span class="dsi-root-symbol">∛</span><span class="dsi-root-content">${baseHTML}</span></span>`;
} else if (Math.abs(this.exponent - 0.25) < 0.0001) {
// Fourth root.
return options.oneLine ? `∜(${baseHTML})`
: `<span class="dsi-root"><span class="dsi-root-symbol">∜</span><span class="dsi-root-content">${baseHTML}</span></span>`;
} else {
return options.oneLine
? `${baseHTML}^(${this.exponent})`
: `${baseHTML}<sup class="dsi-exponent">${this.exponent}</sup>`;
}
} else {
return options.oneLine
? `${baseHTML}^(<span class="dsi-invalid">${this.exponent}</span>)`
: `${baseHTML}<sup class="dsi-exponent"><span class="dsi-invalid">${this.exponent}</span></sup>`;
}
}
return baseHTML;
}
toString() {
let str = '';
if (this.prefix) str += '\\' + this.prefix;
str += '\\' + this.unit;
if (this.exponent !== 1) str += `\\tothe{${this.exponent}}`;
return str;
}
}
// src/index.js
export { DSIUnit } from "./dsiUnit.js";
export { DSIUnitInput } from "./dsiUnitInput.js";
\ No newline at end of file
// unitStrings.js
export const dsiPrefixesHTML = {
deca: 'da',
hecto: 'h',
kilo: 'k',
mega: 'M',
giga: 'G',
tera: 'T',
peta: 'P',
exa: 'E',
zetta: 'Z',
yotta: 'Y',
deci: 'd',
centi: 'c',
milli: 'm',
micro: 'µ',
nano: 'n',
pico: 'p',
femto: 'f',
atto: 'a',
zepto: 'z',
yocto: 'y',
kibi: 'Ki',
mebi: 'Mi',
gibi: 'Gi',
tebi: 'Ti',
pebi: 'Pi',
exbi: 'Ei',
zebi: 'Zi',
yobi: 'Yi'
};
export const dsiUnitsHTML = {
metre: 'm',
kilogram: 'kg',
second: 's',
ampere: 'A',
kelvin: 'K',
mole: 'mol',
candela: 'cd',
one: '1',
day: 'd',
hour: 'h',
minute: 'min',
degree: '°',
arcminute: '',
arcsecond: '',
gram: 'g',
radian: 'rad',
steradian: 'sr',
hertz: 'Hz',
newton: 'N',
pascal: 'Pa',
joule: 'J',
watt: 'W',
coulomb: 'C',
volt: 'V',
farad: 'F',
ohm: 'Ω',
siemens: 'S',
weber: 'Wb',
tesla: 'T',
henry: 'H',
degreecelsius: '°C',
lumen: 'lm',
lux: 'lx',
becquerel: 'Bq',
sievert: 'Sv',
gray: 'Gy',
katal: 'kat',
hectare: 'ha',
litre: 'l',
tonne: 't',
electronvolt: 'eV',
dalton: 'Da',
astronomicalunit: 'au',
neper: 'Np',
bel: 'B',
decibel: 'dB',
percent: '%',
ppm: 'ppm',
byte: 'Byte',
bit: 'bit',
angstrom: 'Å',
bar: 'bar',
// Extend as needed...
};
export const dsiKeyWords = {
tothe: '\\tothe',
per: '\\per'
};
/**
* @jest-environment jsdom
*/
import "../src/dsiUnitInput.js";
import { DSIUnit } from "../src/dsiUnit.js";
describe("DSIUnitInput custom element", () => {
let element;
beforeEach(() => {
document.body.innerHTML = `<dsi-unit-input></dsi-unit-input>`;
element = document.querySelector("dsi-unit-input");
});
test("initializes with an input field", () => {
const input = element.shadowRoot.querySelector("#dsiInput");
expect(input).not.toBeNull();
});
test("shows suggestions after typing backslash and letters", () => {
const input = element.shadowRoot.querySelector("#dsiInput");
input.value = "\\m";
input.dispatchEvent(new Event("input"));
const suggestions = element.shadowRoot.querySelectorAll(".autocomplete-item");
expect(suggestions.length).toBeGreaterThan(0);
suggestions.forEach(item => {
expect(item.textContent).toMatch(/^\\m/);
});
});
test("on blur, input is replaced by rendered output", () => {
const input = element.shadowRoot.querySelector("#dsiInput");
const display = element.shadowRoot.querySelector("#dsiDisplay");
input.value = "\\metre";
input.dispatchEvent(new Event("input"));
input.dispatchEvent(new Event("blur"));
expect(input.style.display).toBe("none");
expect(display.style.display).toBe("block");
const unit = new DSIUnit("\\metre");
expect(display.innerHTML).toBe(unit.toHTML());
});
test("on focus, raw value is restored", () => {
const input = element.shadowRoot.querySelector("#dsiInput");
input.value = "\\metre\\tothe{2}";
input.dispatchEvent(new Event("input"));
input.dispatchEvent(new Event("blur"));
input.dispatchEvent(new Event("focus"));
expect(input.style.display).toBe("block");
expect(input.value).toBe("\\metre\\tothe{2}");
});
test("external suggestions list overrides defaults", () => {
// Set the attribute "suggestions-list" to only allow "foo" and "bar"
element.setAttribute("suggestions-list", "foo,bar");
// Force re-read of the attribute by calling attributeChangedCallback manually if needed.
const input = element.shadowRoot.querySelector("#dsiInput");
input.value = "\\f";
input.dispatchEvent(new Event("input"));
const suggestions = element.shadowRoot.querySelectorAll(".autocomplete-item");
expect(suggestions.length).toBeGreaterThan(0);
suggestions.forEach(item => {
expect(item.textContent).toMatch(/^\\(foo|bar)$/);
});
});
});
// tests/dsiUnits.test.js
import { DSIUnit } from '../src/dsiUnit.js';
describe('DSIUnit HTML Output Tests', () => {
test('Base case: one unit without prefix or exponent', () => {
const unit = new DSIUnit('\\metre');
expect(unit.tree.length).toBe(1);
expect(unit.tree[0].length).toBe(1);
expect(unit.tree[0][0].unit).toBe('metre');
expect(unit.tree[0][0].exponent).toBe(1);
expect(unit.toHTML()).toBe('<span class="dsi-unit-wrapper"><span class="dsi-unit">m</span></span>');
expect(unit.valid).toBe(true);
expect(unit.warnings).toEqual([]);
});
test('Exponent handling: metre^2 and square-root', () => {
const unit2 = new DSIUnit('\\metre\\tothe{2}');
expect(unit2.toHTML()).toBe('<span class="dsi-unit-wrapper"><span class="dsi-unit">m</span><sup class="dsi-exponent">2</sup></span>');
const unitSqrt = new DSIUnit('\\metre\\tothe{0.5}');
expect(unitSqrt.toHTML()).toBe('<span class="dsi-unit-wrapper"><span class="dsi-root"><span class="dsi-root-symbol">√</span><span class="dsi-root-content"><span class="dsi-unit">m</span></span></span></span>');
});
test('Fraction: e.g. mega metre per second^2', () => {
const unitFrac = new DSIUnit('\\mega\\metre\\per\\second\\tothe{2}');
const expectedHTML = '<span class="dsi-unit-wrapper"><span class="dsi-fraction"><span class="dsi-numerator"><span class="dsi-unit">M</span><span class="dsi-unit">m</span></span><span class="dsi-denom"><span class="dsi-unit">s</span><sup class="dsi-exponent">2</sup></span></span></span>';
expect(unitFrac.toHTML()).toBe(expectedHTML);
});
test('Robustness: unknown unit', () => {
const unitUnknown = new DSIUnit('\\foo');
const expectedHTML = '<span class="dsi-unit-wrapper" title="The identifier «foo» does not match any D-SI units!"><span class="dsi-error"><span class="dsi-unit"><span class="dsi-invalid">foo</span></span></span></span>';
expect(unitUnknown.toHTML()).toBe(expectedHTML);
expect(unitUnknown.valid).toBe(false);
expect(unitUnknown.warnings).toContain('The identifier «foo» does not match any D-SI units!');
});
test('Non D-SI unit marker', () => {
const nonDsi = new DSIUnit('|NonDsiUnit');
const expectedHTML = '<span class="dsi-unit-wrapper"><span class="dsi-nonunit">NonDsiUnit</span></span>';
expect(nonDsi.toHTML()).toBe(expectedHTML);
});
test('Empty string', () => {
const emptyUnit = new DSIUnit('');
const expectedHTML = '<span class="dsi-unit-wrapper"><span class="dsi-nonunit">NULL</span></span>';
expect(emptyUnit.toHTML()).toBe(expectedHTML);
expect(emptyUnit.warnings).toContain('Given D-SI string is empty!');
});
test('Double backslash handling', () => {
const unitDouble = new DSIUnit('\\\\metre\\per\\second');
const expectedHTML = '<span class="dsi-unit-wrapper" title="Double backslash found in string, treating as one backslash: «\\metre\\per\\second»"><span class="dsi-error"><span class="dsi-fraction"><span class="dsi-numerator"><span class="dsi-unit">m</span></span><span class="dsi-denom"><span class="dsi-unit">s</span></span></span></span></span>';
expect(unitDouble.toHTML()).toBe(expectedHTML);
expect(unitDouble.warnings.some(w => w.includes('Double backslash'))).toBe(true);
});
test('Fourth root: micro metre tothe{0.25}', () => {
const unitFourth = new DSIUnit('\\micro\\metre\\tothe{0.25}');
const expectedHTML = '<span class="dsi-unit-wrapper"><span class="dsi-root"><span class="dsi-root-symbol">∜</span><span class="dsi-root-content"><span class="dsi-unit">µ</span><span class="dsi-unit">m</span></span></span></span>';
expect(unitFourth.toHTML()).toBe(expectedHTML);
});
});
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>D-SI Units Visual Test & Documentation</title>
<style>
body {
font-family: sans-serif;
margin: 20px;
line-height: 1.6;
}
h1, h2, h3 {
color: #333;
}
table {
border-collapse: collapse;
width: 100%;
margin-bottom: 40px;
}
th, td {
border: 1px solid #ccc;
padding: 8px;
text-align: left;
vertical-align: top;
}
th {
background-color: #eee;
}
pre {
margin: 0;
font-family: monospace;
background: #f8f8f8;
padding: 4px;
}
.dsi-invalid {
color: red;
font-weight: bold;
}
.dsi-error {
color: orange;
}
.dsi-fraction {
display: inline-block;
text-align: center;
vertical-align: middle;
}
.dsi-numerator {
display: block;
border-bottom: 1px solid #000;
padding: 0 2px;
line-height: 1.2;
}
.dsi-denom {
display: block;
padding: 0 2px;
line-height: 1.2;
}
.dsi-root {
display: inline-flex;
align-items: flex-start;
}
.dsi-root-symbol {
font-size: 1.2em;
padding-right: 2px;
}
.dsi-root-content {
border-top: 1px solid #000;
display: inline-block;
padding-top: 2px;
}
.dsi-mul {
padding: 0 2px;
}
.dsi-unit-wrapper {}
#documentation {
margin-bottom: 40px;
padding: 10px;
border: 1px solid #ccc;
background-color: #fafafa;
}
.doc-table {
margin-bottom: 20px;
}
.doc-table th {
background-color: #ddd;
}
/* Styles for our custom auto-complete input */
dsi-unit-input {
display: block;
margin-bottom: 40px;
}
.autocomplete-item.selected {
background-color: #bde4ff;
}
</style>
</head>
<body>
<h1>D-SI Units Visual Test & Documentation</h1>
<div id="documentation">
<h2>How D-SI Units Are Composed</h2>
<p>
D-SI units are represented as strings where each component is preceded by a backslash (<code>\</code>).
A basic D-SI unit string is composed of:
</p>
<ul>
<li>An <strong>optional prefix</strong> (e.g. <code>\kilo</code> for 10³, <code>\milli</code> for 10⁻³),</li>
<li>A <strong>base unit</strong> (e.g. <code>\metre</code>, <code>\second</code>),</li>
<li>An optional exponent specified with <code>\tothe{...}</code> (e.g. <code>\metre\tothe{2}</code> for square metre),
with fractional exponents like 0.5, 0.333333, or 0.25 rendered as square, cube, or fourth roots respectively.</li>
<li>An optional fraction operator <code>\per</code> is used to combine units into a fraction (e.g. <code>\metre\per\second</code> for metres per second).</li>
</ul>
<p>
Use the auto-complete input below to type a D-SI string. After typing a backslash and at least one letter,
suggestions will appear based on the allowed tokens. When the field loses focus, the rendered unit is shown (while
the raw string is preserved for further editing when focused again). External suggestion lists can also be provided.
</p>
</div>
<!-- New auto-complete input component -->
<h2>Interactive D-SI Input</h2>
<dsi-unit-input suggestions-list="kilo,mega,milli,metre,second,per,tothe"></dsi-unit-input>
<h2>Allowed Prefixes</h2>
<table id="prefixes-table" class="doc-table">
<thead>
<tr>
<th>Prefix Name</th>
<th>Symbol</th>
</tr>
</thead>
<tbody></tbody>
</table>
<h2>Allowed Units</h2>
<table id="units-table" class="doc-table">
<thead>
<tr>
<th>Unit Name</th>
<th>Symbol</th>
</tr>
</thead>
<tbody></tbody>
</table>
<h2>Example D-SI Strings</h2>
<h3>Valid D-SI Strings</h3>
<table id="valid-table">
<thead>
<tr>
<th>Raw D-SI String</th>
<th>Rendered Output</th>
</tr>
</thead>
<tbody></tbody>
</table>
<h3>Invalid D-SI Strings</h3>
<table id="invalid-table">
<thead>
<tr>
<th>Raw D-SI String</th>
<th>Rendered Output</th>
<th>Warnings</th>
</tr>
</thead>
<tbody></tbody>
</table>
<!-- Existing interactive section (button controlled) -->
<h2>Interactive Renderer (Button Controlled)</h2>
<div id="interactiveSection">
<label for="dsiInput">Enter D-SI string:</label>
<input type="text" id="dsiInput" placeholder="e.g. \metre\tothe{0.333333}">
<button id="renderBtn">Render</button>
<div id="interactiveResult"></div>
</div>
<script type="module">
import { dsiPrefixesHTML, dsiUnitsHTML } from "../src/unitStrings.js";
import { DSIUnit } from "../src/dsiUnit.js";
import "../src/dsiUnitInput.js";
// Populate Allowed Prefixes table.
const prefixesTbody = document.getElementById('prefixes-table').querySelector('tbody');
Object.keys(dsiPrefixesHTML).sort().forEach(prefix => {
const tr = document.createElement('tr');
const tdName = document.createElement('td');
tdName.textContent = prefix;
const tdSymbol = document.createElement('td');
tdSymbol.textContent = dsiPrefixesHTML[prefix];
tr.appendChild(tdName);
tr.appendChild(tdSymbol);
prefixesTbody.appendChild(tr);
});
// Populate Allowed Units table.
const unitsTbody = document.getElementById('units-table').querySelector('tbody');
Object.keys(dsiUnitsHTML).sort().forEach(unit => {
const tr = document.createElement('tr');
const tdName = document.createElement('td');
tdName.textContent = unit;
const tdSymbol = document.createElement('td');
tdSymbol.textContent = dsiUnitsHTML[unit];
tr.appendChild(tdName);
tr.appendChild(tdSymbol);
unitsTbody.appendChild(tr);
});
// Example D-SI strings.
const validDSI = [
'\\metre',
'\\metre\\tothe{2}',
'\\metre\\tothe{0.5}',
'\\mega\\metre\\per\\second\\tothe{2}',
'\\kilo\\metre\\per\\hour',
'\\metre\\per\\second',
'\\metre\\tothe{0.333333}', // Cube root
'\\metre\\tothe{0.25}' // Fourth root
];
const invalidDSI = [
'\\foo',
'\\milli\\tothe{2}',
'\\metre\\per',
'\\\\metre\\per\\second'
];
function renderDSI(rawStr) {
try {
const dsi = new DSIUnit(rawStr);
return {
html: dsi.toHTML(),
warnings: dsi.warnings
};
} catch (error) {
return {
html: `<span style="color:red;">Error: ${error.message}</span>`,
warnings: [error.message]
};
}
}
function populateTable(tableId, dsiStrings) {
const tbody = document.getElementById(tableId).querySelector('tbody');
dsiStrings.forEach(rawStr => {
const result = renderDSI(rawStr);
const tr = document.createElement('tr');
const tdRaw = document.createElement('td');
tdRaw.innerHTML = `<pre>${rawStr}</pre>`;
tr.appendChild(tdRaw);
const tdRendered = document.createElement('td');
tdRendered.innerHTML = result.html;
tr.appendChild(tdRendered);
if (tableId === 'invalid-table') {
const tdWarnings = document.createElement('td');
tdWarnings.innerHTML = result.warnings.join('<br>');
tr.appendChild(tdWarnings);
}
tbody.appendChild(tr);
});
}
populateTable('valid-table', validDSI);
populateTable('invalid-table', invalidDSI);
// Button-controlled interactive section.
const renderBtn = document.getElementById('renderBtn');
renderBtn.addEventListener('click', () => {
const inputStr = document.getElementById('dsiInput').value;
try {
const unit = new DSIUnit(inputStr);
const renderedHTML = unit.toHTML();
let warningsHTML = "";
if (unit.warnings.length > 0) {
warningsHTML = `<div id="warningText">Warnings: ${unit.warnings.join("; ")}</div>`;
}
document.getElementById('interactiveResult').innerHTML = renderedHTML + warningsHTML;
} catch (err) {
document.getElementById('interactiveResult').innerHTML = `<span style="color:red;">Error: ${err.message}</span>`;
}
});
</script>
</body>
</html>
...@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" ...@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "dsiunits" # Ensure this is correctly specified name = "dsiunits" # Ensure this is correctly specified
version = "2.4.0" version = "2.4.1"
description = "This is a Python module for handling the SI units as objects in Python, parsing them from strings and converting them to Latex and Unicode, as well as performing math operations and calculating scale factors." description = "This is a Python module for handling the SI units as objects in Python, parsing them from strings and converting them to Latex and Unicode, as well as performing math operations and calculating scale factors."
authors = [ authors = [
{ name="Benedikt Seeger", email="benedikt.seeger@ptb.de" }, { name="Benedikt Seeger", email="benedikt.seeger@ptb.de" },
......
[metadata]
name = dsiUnits
version = 2.4.0
description = This is a Python module for handling the SI units as objects in Python, parsing them from strings and converting them to Latex and Unicode, as well as performing math operations and calculating scale factors.
long_description = file: README.md
[options]
python_requires = >=3.7
[options.extras_require]
testing =
pytest>=7.4.1
pytest-cov>=4.1.0
# Any other testing dependencies
...@@ -588,6 +588,10 @@ class dsiUnit: ...@@ -588,6 +588,10 @@ class dsiUnit:
result += str(node) result += str(node)
return result return result
def __hash__(self):
# Use the hash of an immutable attribute (here, self.value)
return hash(str(self))
def __repr__(self): def __repr__(self):
contentStr = self.toUTF8() contentStr = self.toUTF8()
if not self.valid: if not self.valid:
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment