Skip to content
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
185 changes: 174 additions & 11 deletions CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
package com.codename1.ui.css;

import com.codename1.ui.Component;
import com.codename1.ui.EncodedImage;
import com.codename1.ui.Image;
import com.codename1.ui.plaf.CSSBorder;
Expand Down Expand Up @@ -40,6 +41,12 @@
/// - `var(--name)` dereferencing in declaration values.
public class CSSThemeCompiler {

public static class CSSSyntaxException extends IllegalArgumentException {
public CSSSyntaxException(String message) {
super(message);
}
}

public void compile(String css, MutableResource resources, String themeName) {
Hashtable theme = resources.getTheme(themeName);
if (theme == null) {
Expand Down Expand Up @@ -81,7 +88,7 @@ private void compileConstants(String css, Hashtable theme) {
}
int close = stripped.indexOf('}', open + 1);
if (close <= open) {
return;
throw new CSSSyntaxException("Unterminated @constants block");
}
Declaration[] declarations = parseDeclarations(stripped.substring(open + 1, close));
for (Declaration declaration : declarations) {
Expand Down Expand Up @@ -166,6 +173,11 @@ private boolean applySimpleThemeProperty(Hashtable theme, String uiid, String st
theme.put(uiid + "." + statePrefix + "font", value);
return true;
}
if ("text-align".equals(property)) {
String align = normalizeAlignment(value);
theme.put(uiid + "." + statePrefix + "align", align);
return true;
}
return false;
}

Expand Down Expand Up @@ -267,10 +279,33 @@ private boolean isBorderProperty(String property) {
}

private String normalizeHexColor(String cssColor) {
String value = cssColor.trim();
if ("transparent".equalsIgnoreCase(value)) {
String value = cssColor == null ? "" : cssColor.trim().toLowerCase();
if (value.length() == 0) {
throw new CSSSyntaxException("Color value cannot be empty");
}
if ("transparent".equals(value)) {
return "000000";
}

if (value.startsWith("rgb(")) {
if (!value.endsWith(")")) {
throw new CSSSyntaxException("Malformed rgb() color: " + cssColor);
}
String[] parts = splitOnComma(value.substring(4, value.length() - 1));
if (parts.length != 3) {
throw new CSSSyntaxException("rgb() must have exactly 3 components: " + cssColor);
}
int r = parseRgbChannel(parts[0], cssColor);
int g = parseRgbChannel(parts[1], cssColor);
int b = parseRgbChannel(parts[2], cssColor);
return toHexColor((r << 16) | (g << 8) | b);
}

String keyword = cssColorKeyword(value);
if (keyword != null) {
return keyword;
}

if (value.startsWith("#")) {
value = value.substring(1);
}
Expand All @@ -279,7 +314,92 @@ private String normalizeHexColor(String cssColor) {
+ value.charAt(1) + value.charAt(1)
+ value.charAt(2) + value.charAt(2);
}
return value.toLowerCase();
if (value.length() != 6 || !isHexColor(value)) {
throw new CSSSyntaxException("Unsupported color value: " + cssColor);
}
return value;
}

private String normalizeAlignment(String value) {
String v = value == null ? "" : value.trim().toLowerCase();
if ("left".equals(v) || "start".equals(v)) {
return String.valueOf(Component.LEFT);
}
if ("center".equals(v)) {
return String.valueOf(Component.CENTER);
}
if ("right".equals(v) || "end".equals(v)) {
return String.valueOf(Component.RIGHT);
}
throw new CSSSyntaxException("Unsupported text-align value: " + value);
}

private String cssColorKeyword(String value) {
if ("black".equals(value)) {
return "000000";
}
if ("white".equals(value)) {
return "ffffff";
}
if ("red".equals(value)) {
return "ff0000";
}
if ("green".equals(value)) {
return "008000";
}
if ("blue".equals(value)) {
return "0000ff";
}
if ("pink".equals(value)) {
return "ffc0cb";
}
if ("orange".equals(value)) {
return "ffa500";
}
if ("yellow".equals(value)) {
return "ffff00";
}
if ("purple".equals(value)) {
return "800080";
}
if ("gray".equals(value) || "grey".equals(value)) {
return "808080";
}
return null;
}

private int parseRgbChannel(String value, String originalColor) {
int out;
try {
out = Integer.parseInt(value.trim());
} catch (RuntimeException err) {
throw new CSSSyntaxException("Invalid rgb() channel value in " + originalColor + ": " + value);
}
if (out < 0 || out > 255) {
throw new CSSSyntaxException("rgb() channel out of range in " + originalColor + ": " + value);
}
return out;
}

private boolean isHexColor(String value) {
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
boolean hex = (c >= '0' && c <= '9')
|| (c >= 'a' && c <= 'f')
|| (c >= 'A' && c <= 'F');
if (!hex) {
return false;
}
}
return true;
}

private String toHexColor(int color) {
String hex = Integer.toHexString(color & 0xffffff);
while (hex.length() < 6) {
hex = "0" + hex;
}
return hex;
}

private String normalizeBox(String cssValue) {
Expand Down Expand Up @@ -312,28 +432,40 @@ private Rule[] parseRules(String css) {
ArrayList<Rule> out = new ArrayList<Rule>();
int pos = 0;
while (pos < stripped.length()) {
while (pos < stripped.length() && Character.isWhitespace(stripped.charAt(pos))) {
pos++;
}
if (pos >= stripped.length()) {
break;
}
int open = stripped.indexOf('{', pos);
if (open < 0) {
break;
throw new CSSSyntaxException("Missing '{' in CSS rule near: " + stripped.substring(pos));
}
int close = stripped.indexOf('}', open + 1);
if (close < 0) {
break;
throw new CSSSyntaxException("Missing '}' for CSS rule: " + stripped.substring(pos, open).trim());
}
if (stripped.indexOf('{', open + 1) > -1 && stripped.indexOf('{', open + 1) < close) {
throw new CSSSyntaxException("Nested '{' is not supported in CSS block: " + stripped.substring(pos, open).trim());
}

String selectors = stripped.substring(pos, open).trim();
if (selectors.startsWith("@constants")) {
pos = close + 1;
continue;
}
if (selectors.length() == 0) {
throw new CSSSyntaxException("Missing selector before '{'");
}

String body = stripped.substring(open + 1, close).trim();
Declaration[] declarations = parseDeclarations(body);
String[] selectorsList = splitOnChar(selectors, ',');
for (String selectorEntry : selectorsList) {
String selector = selectorEntry.trim();
if (selector.length() == 0) {
continue;
throw new CSSSyntaxException("Empty selector in selector list: " + selectors);
}
Rule rule = new Rule();
rule.selector = selector;
Expand All @@ -353,13 +485,18 @@ private String stripComments(String css) {
char c = css.charAt(i);
if (c == '/' && i + 1 < css.length() && css.charAt(i + 1) == '*') {
i += 2;
boolean closed = false;
while (i + 1 < css.length()) {
if (css.charAt(i) == '*' && css.charAt(i + 1) == '/') {
i += 2;
closed = true;
break;
}
i++;
}
if (!closed) {
throw new CSSSyntaxException("Unterminated CSS comment");
}
continue;
}
out.append(c);
Expand All @@ -372,13 +509,20 @@ private Declaration[] parseDeclarations(String body) {
ArrayList<Declaration> out = new ArrayList<Declaration>();
String[] segments = splitOnChar(body, ';');
for (String line : segments) {
int colon = line.indexOf(':');
if (colon <= 0) {
String trimmed = line.trim();
if (trimmed.length() == 0) {
continue;
}
int colon = trimmed.indexOf(':');
if (colon <= 0 || colon == trimmed.length() - 1) {
throw new CSSSyntaxException("Malformed declaration: " + trimmed);
}
Declaration dec = new Declaration();
dec.property = line.substring(0, colon).trim().toLowerCase();
dec.value = line.substring(colon + 1).trim();
dec.property = trimmed.substring(0, colon).trim().toLowerCase();
dec.value = trimmed.substring(colon + 1).trim();
if (dec.property.length() == 0 || dec.value.length() == 0) {
throw new CSSSyntaxException("Malformed declaration: " + trimmed);
}
out.add(dec);
}
return out.toArray(new Declaration[out.size()]);
Expand All @@ -398,6 +542,25 @@ private String[] splitOnChar(String input, char delimiter) {
return out.toArray(new String[out.size()]);
}

private String[] splitOnComma(String input) {
ArrayList<String> parts = new ArrayList<String>();
int start = 0;
for (int i = 0; i < input.length(); i++) {
if (input.charAt(i) == ',') {
String token = input.substring(start, i).trim();
if (token.length() > 0) {
parts.add(token);
}
start = i + 1;
}
}
String tail = input.substring(start).trim();
if (tail.length() > 0) {
parts.add(tail);
}
return parts.toArray(new String[parts.size()]);
}

private String[] splitOnWhitespace(String input) {
ArrayList<String> out = new ArrayList<String>();
StringBuilder token = new StringBuilder();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.codename1.ui.css;

import com.codename1.junit.UITestBase;
import com.codename1.ui.Component;
import com.codename1.ui.Image;
import com.codename1.ui.plaf.CSSBorder;
import com.codename1.ui.util.MutableResource;
Expand All @@ -10,6 +11,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;

public class CSSThemeCompilerTest extends UITestBase {

Expand All @@ -23,13 +25,14 @@ public void testCompilesThemeConstantsDeriveAndMutableImages() {
+ "@constants{spacing: 4px; primaryColor: var(--primary);}"
+ "Button{color:var(--primary);background-color:#112233;padding:1px 2px;cn1-derive:Label;}"
+ "Button:pressed{border-width:2px;border-style:solid;border-color:#ffffff;cn1-mutable-image:btnBg #ff00ff;}"
+ "Label{margin:2px 4px 6px 8px;}",
+ "Label{margin:2px 4px 6px 8px;}"
+ "Button{color:pink;text-align:center;}",
resource,
"Theme"
);

Hashtable theme = resource.getTheme("Theme");
assertEquals("aabbcc", theme.get("Button.fgColor"));
assertEquals("ffc0cb", theme.get("Button.fgColor"));
assertEquals("112233", theme.get("Button.bgColor"));
assertEquals("255", theme.get("Button.transparency"));
assertEquals("1,2,1,2", theme.get("Button.padding"));
Expand All @@ -38,10 +41,24 @@ public void testCompilesThemeConstantsDeriveAndMutableImages() {
assertEquals("#abc", theme.get("@primary"));
assertEquals("4px", theme.get("@spacing"));
assertEquals("#abc", theme.get("@primarycolor"));
assertEquals(String.valueOf(Component.CENTER), String.valueOf(theme.get("Button.align")));
assertTrue(theme.get("Button.press#border") instanceof CSSBorder);

Image mutable = resource.getImage("btnBg");
assertNotNull(mutable);
assertNotNull(theme.get("Button.press#bgImage"));
}
@Test
public void testThrowsOnMalformedCss() {
CSSThemeCompiler compiler = new CSSThemeCompiler();
MutableResource resource = new MutableResource();

assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () ->
compiler.compile("Button{color:#12;}", resource, "Theme")
);
assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () ->
compiler.compile("Button{color:#ff00ff;text-align:middle;}", resource, "Theme")
);
}

}
Loading
Loading